diff --git a/package-lock.json b/package-lock.json index f3b744d..75adf77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "express": "^4.19.2", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.3", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "multer": "^2.1.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -24,6 +26,7 @@ "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.9", + "@types/multer": "^2.1.0", "@types/node": "^20.12.12", "prisma": "^6.19.3", "rimraf": "^5.0.7", @@ -770,6 +773,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", @@ -881,6 +894,15 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -921,6 +943,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -1038,9 +1066,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1108,6 +1146,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1134,6 +1185,15 @@ "consola": "^3.2.3" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1161,6 +1221,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -1231,6 +1306,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -1630,6 +1717,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -2226,6 +2322,25 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2548,6 +2663,20 @@ "destr": "^2.0.3" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2818,6 +2947,18 @@ "source-map": "^0.6.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2827,6 +2968,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3228,6 +3386,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3259,6 +3423,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -3300,6 +3470,24 @@ "node": ">= 8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -3405,6 +3593,27 @@ "dev": true, "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 351f0e0..d1b18ed 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "express": "^4.19.2", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.3", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "multer": "^2.1.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -32,6 +34,7 @@ "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.9", + "@types/multer": "^2.1.0", "@types/node": "^20.12.12", "prisma": "^6.19.3", "rimraf": "^5.0.7", diff --git a/prisma/seed_dummy.ts b/prisma/seed_dummy.ts new file mode 100644 index 0000000..8812187 --- /dev/null +++ b/prisma/seed_dummy.ts @@ -0,0 +1,249 @@ +import 'dotenv/config'; +import { PrismaClient } from '../src/generated/prisma/client'; +import bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('=== STARTING DEVELOPER DUMMY SEEDING ==='); + + const adminEmail = 'admin@internify.com'; + const adminPassword = 'AdminInternify123!'; + const passwordHash = await bcrypt.hash(adminPassword, 10); + + let adminUser = await prisma.user.findUnique({ where: { email: adminEmail } }); + if (!adminUser) { + adminUser = await prisma.user.create({ + data: { + email: adminEmail, + passwordHash, + role: 'ADMIN', + admin: { + create: { + namaDepan: 'Super', + namaBelakang: 'Admin' + } + } + } + }); + console.log(' Admin created:', adminEmail); + } else { + console.log(' Admin already exists:', adminEmail); + } + + console.log(' Seeding Batches...'); + const batchesData = [ + { batchNumber: 1, isActive: false }, + { batchNumber: 2, isActive: false }, + { batchNumber: 3, isActive: true } // Active Batch + ]; + + const batches: any[] = []; + for (const b of batchesData) { + let dbBatch = await prisma.batch.findUnique({ where: { batchNumber: b.batchNumber } }); + if (!dbBatch) { + dbBatch = await prisma.batch.create({ data: b }); + } + batches.push(dbBatch); + } + console.log(` Seeding Batches complete! Created/Found ${batches.length} batches.`); + + const activeBatch = batches.find(b => b.isActive) || batches[2]; + + console.log(' Seeding Student Users...'); + const studentsData = [ + { + email: 'alice@example.com', + namaDepan: 'Alice', + namaBelakang: 'Smith', + kontak: '08111222333', + jurusan: 'Informatika', + universitas: 'Institut Teknologi Bandung', + negara: 'Indonesia', + cvPath: '/uploads/docs/alice-cv.pdf', + portofolioPath: '/uploads/docs/alice-portfolio.pdf', + motivasi: 'Saya sangat tertarik dengan UI/UX design dan ingin mempraktekkannya.', + relevantSkills: 'Figma, Adobe XD, User Research' + }, + { + email: 'bob@example.com', + namaDepan: 'Bob', + namaBelakang: 'Johnson', + kontak: '08222333444', + jurusan: 'Sistem Informasi', + universitas: 'Universitas Indonesia', + negara: 'Indonesia', + cvPath: '/uploads/docs/bob-cv.pdf', + portofolioPath: '/uploads/docs/bob-portfolio.pdf', + motivasi: 'Ingin mengembangkan keahlian React dan Tailwind CSS.', + relevantSkills: 'React, HTML, CSS, JavaScript' + }, + { + email: 'charlie@example.com', + namaDepan: 'Charlie', + namaBelakang: 'Brown', + kontak: '08333444555', + jurusan: 'Teknik Komputer', + universitas: 'Telkom University', + negara: 'Indonesia', + cvPath: '/uploads/docs/charlie-cv.pdf', + portofolioPath: '/uploads/docs/charlie-portfolio.pdf', + motivasi: 'Suka membangun server scalable dan merancang arsitektur API.', + relevantSkills: 'Node.js, Express, MySQL, TypeScript' + } + ]; + + const mahasiswaList: any[] = []; + for (const s of studentsData) { + let user = await prisma.user.findUnique({ + where: { email: s.email }, + include: { mahasiswa: true } + }); + + if (!user) { + const hash = await bcrypt.hash('Student123!', 10); + user = await prisma.user.create({ + data: { + email: s.email, + passwordHash: hash, + role: 'STUDENT', + mahasiswa: { + create: { + namaDepan: s.namaDepan, + namaBelakang: s.namaBelakang, + kontak: s.kontak, + jurusan: s.jurusan, + universitas: s.universitas, + negara: s.negara, + cvPath: s.cvPath, + portofolioPath: s.portofolioPath, + motivasi: s.motivasi, + relevantSkills: s.relevantSkills + } + } + }, + include: { mahasiswa: true } + }); + } + mahasiswaList.push(user.mahasiswa); + } + console.log(` Student seeding complete! Created/Found ${mahasiswaList.length} students.`); + + console.log(' Seeding Lowongan Listings...'); + const lowongansData = [ + { + id: 'lowongan-ui-ux', + posisi: 'UI/UX Designer Intern', + kelompokPeminatan: 'Design & Creative', + imagePath: '/uploads/images/ui-ux-design.png', + jobdesk: '
Mendesain antarmuka aplikasi mobile dan web, melakukan user testing, dan wireframing.
', + lokasi: 'Onsite - Bandung', + kualifikasi: 'Menguasai Figma, memiliki portofolio desain UI/UX, kreatif.', + benefit: 'Sertifikat magang resmi, uang transport harian, mentoring 1-on-1.', + paid: 'PAID' as any, + statusLowongan: 'DIBUKA' as any + }, + { + id: 'lowongan-frontend', + posisi: 'Frontend Developer Intern', + kelompokPeminatan: 'Web Engineering', + imagePath: '/uploads/images/frontend-dev.png', + jobdesk: 'Mengembangkan antarmuka responsif menggunakan React dan Tailwind CSS.
', + lokasi: 'Remote', + kualifikasi: 'Memahami React JS, ES6 JavaScript, HTML, CSS.', + benefit: 'Sertifikat magang resmi, jam kerja fleksibel, free access learning course.', + paid: 'UNPAID' as any, + statusLowongan: 'DIBUKA' as any + }, + { + id: 'lowongan-backend', + posisi: 'Backend Developer Intern', + kelompokPeminatan: 'Web Engineering', + imagePath: '/uploads/images/backend-dev.png', + jobdesk: 'Membangun REST API, mengoptimalkan query database, dan integrasi backend services.
', + lokasi: 'Hybrid - Jakarta', + kualifikasi: 'Memahami Node.js, Express, dan Relational Database seperti MySQL.', + benefit: 'Sertifikat magang resmi, konversi SKS, coworking space access.', + paid: 'PAID' as any, + statusLowongan: 'DIBUKA' as any + } + ]; + + const lowonganList: any[] = []; + for (const l of lowongansData) { + let dbLowongan = await prisma.lowonganMagang.findUnique({ where: { id: l.id } }); + if (!dbLowongan) { + dbLowongan = await prisma.lowonganMagang.create({ + data: { + id: l.id, + idBatch: activeBatch.id, + posisi: l.posisi, + kelompokPeminatan: l.kelompokPeminatan, + imagePath: l.imagePath, + jobdesk: l.jobdesk, + lokasi: l.lokasi, + kualifikasi: l.kualifikasi, + benefit: l.benefit, + durasiAwal: new Date(), + durasiAkhir: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days from now + statusLowongan: l.statusLowongan, + paid: l.paid + } + }); + } + lowonganList.push(dbLowongan); + } + console.log(` Lowongan seeding complete! Created/Found ${lowonganList.length} listings.`); + + console.log(' Seeding Lamaran Records...'); + const lamaranData = [ + { + idMahasiswa: mahasiswaList[0].id, + idLowonganMagang: lowonganList[0].id, + status: 'DIPROSES' as any + }, + { + idMahasiswa: mahasiswaList[1].id, + idLowonganMagang: lowonganList[1].id, + status: 'DIPROSES' as any + }, + { + idMahasiswa: mahasiswaList[2].id, + idLowonganMagang: lowonganList[2].id, + status: 'DIPROSES' as any + } + ]; + + let lamaranCount = 0; + for (const lam of lamaranData) { + const existing = await prisma.lamaranMagang.findFirst({ + where: { + idMahasiswa: lam.idMahasiswa, + idLowonganMagang: lam.idLowonganMagang + } + }); + + if (!existing) { + await prisma.lamaranMagang.create({ + data: { + idMahasiswa: lam.idMahasiswa, + idLowonganMagang: lam.idLowonganMagang, + status: lam.status, + batch: activeBatch.id + } + }); + lamaranCount++; + } + } + console.log(` Lamaran seeding complete! Created ${lamaranCount} new applications.`); + + console.log('\n=== DEVELOPER SEEDING COMPLETED SUCCESSFULLY ==='); +} + +main() + .catch(e => { + console.error('Seeding failed:', e); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app.ts b/src/app.ts index 2fc1e92..975d1c0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,3 +1,4 @@ +import path from 'path'; import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import helmet from 'helmet'; @@ -8,10 +9,17 @@ import { errorHandler, CustomError } from './middleware/errorHandler'; const app = express(); app.use(helmet()); -app.use(cors()); +app.use(cors({ + origin: true, + credentials: true, +})); app.use(morgan('dev')); +// File upload statis biar bisa diakses lewat /uploads atau /api/uploads +app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); +app.use('/api/uploads', express.static(path.join(__dirname, '../uploads'))); + app.use(express.json()); app.use(express.urlencoded({ extended: true })); diff --git a/src/middleware/upload.ts b/src/middleware/upload.ts new file mode 100644 index 0000000..d82a84d --- /dev/null +++ b/src/middleware/upload.ts @@ -0,0 +1,46 @@ +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; + +const storage = multer.diskStorage({ + destination: (_req, file, cb) => { + let folder = 'uploads/'; + if (file.fieldname === 'cv' || file.fieldname === 'portofolio') { + folder += 'docs/'; + } else { + folder += 'images/'; + } + + fs.mkdirSync(folder, { recursive: true }); + cb(null, folder); + }, + filename: (_req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = path.extname(file.originalname); + cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`); + } +}); + +const fileFilter = (_req: any, file: any, cb: any) => { + if (file.fieldname === 'cv' || file.fieldname === 'portofolio') { + if (file.mimetype === 'application/pdf') { + cb(null, true); + } else { + cb(new Error('CV dan Portofolio harus dalam format PDF!'), false); + } + } else { + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('File upload harus berupa gambar!'), false); + } + } +}; + +export const upload = multer({ + storage: storage, + fileFilter: fileFilter, + limits: { + fileSize: 10 * 1024 * 1024, // Maksimal 10MB yaa, nanti paling bisa diganti oleh admin melalui website + } +}); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index b827721..04cfab2 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -17,7 +17,7 @@ const generateToken = (payload: { id: string; email: string; role: string }) => /** * @route POST /api/auth/register - * @desc Mendaftarkan user baru dengan role STUDENT (Mahasiswa) + * @desc Daftarin user baru dgn role STUDENT (Mahasiswa) * @access Public */ router.post('/register', async (req: Request, res: Response, next: NextFunction) => { @@ -101,7 +101,7 @@ router.post('/register', async (req: Request, res: Response, next: NextFunction) /** * @route POST /api/auth/login - * @desc Autentikasi user dan dapatin token + * @desc Autentikasi user biar dapet token * @access Public */ router.post('/login', async (req: Request, res: Response, next: NextFunction) => { @@ -145,12 +145,14 @@ router.post('/login', async (req: Request, res: Response, next: NextFunction) => res.status(200).json({ success: true, - token, - user: { - id: user.id, - email: user.email, - role: user.role, - profile, + data: { + token, + user: { + id: user.id, + email: user.email, + role: user.role, + profile, + }, }, }); } catch (error) { @@ -160,7 +162,7 @@ router.post('/login', async (req: Request, res: Response, next: NextFunction) => /** * @route GET /api/auth/profile - * @desc Dapatin profile user yang sedang login + * @desc Dapetin profil user yg lagi login * @access Private */ router.get('/profile', protect, async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { diff --git a/src/routes/faq.ts b/src/routes/faq.ts new file mode 100644 index 0000000..1d4537a --- /dev/null +++ b/src/routes/faq.ts @@ -0,0 +1,165 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { protect, restrictTo, AuthenticatedRequest } from '../middleware/auth'; +import prisma from '../lib/prisma'; +import { CustomError } from '../middleware/errorHandler'; + +const router = Router(); + +// Map data faq dari DB ke format frontend +function mapToFrontend(faq: any) { + return { + id: faq.id, + pertanyaan: faq.pertanyaan, + jawaban: faq.jawaban, + created_at: faq.createdAt + }; +} + +/** + * @route GET /api/faq-api/get + * @desc Dapetin semua list FAQ + * @access Public + */ +router.get('/get', async (_req: Request, res: Response, next: NextFunction) => { + try { + const faqs = await prisma.faq.findMany({ + orderBy: { createdAt: 'desc' } + }); + res.status(200).json({ + status: true, + data: faqs.map(mapToFrontend) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route GET /api/faq-api/get/:id + * @desc Dapetin detail satu FAQ + * @access Public + */ +router.get('/get/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const faq = await prisma.faq.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!faq) { + const error: CustomError = new Error('FAQ tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + res.status(200).json({ + status: true, + data: mapToFrontend(faq) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route POST /api/faq-api/add + * @desc Tambah FAQ baru (khusus admin ya) + * @access Private (Admin) + */ +router.post('/add', protect, restrictTo('ADMIN'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { pertanyaan, jawaban } = req.body; + + if (!pertanyaan || !jawaban) { + const error: CustomError = new Error('Pertanyaan dan jawaban harus diisi'); + error.statusCode = 400; + return next(error); + } + + const faq = await prisma.faq.create({ + data: { + pertanyaan, + jawaban + } + }); + + res.status(201).json({ + status: true, + message: 'FAQ berhasil ditambahkan', + data: mapToFrontend(faq) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route PATCH /api/faq-api/update/:id + * @desc Update data FAQ (khusus admin) + * @access Private (Admin) + */ +router.patch('/update/:id', protect, restrictTo('ADMIN'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const { pertanyaan, jawaban } = req.body; + + const existing = await prisma.faq.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!existing) { + const error: CustomError = new Error('FAQ tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + const updated = await prisma.faq.update({ + where: { id: parseInt(id, 10) }, + data: { + pertanyaan: pertanyaan ?? existing.pertanyaan, + jawaban: jawaban ?? existing.jawaban + } + }); + + res.status(200).json({ + status: true, + message: 'FAQ berhasil diupdate', + data: mapToFrontend(updated) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route DELETE /api/faq-api/delete/:id + * @desc Hapus data FAQ (khusus admin) + * @access Private (Admin) + */ +router.delete('/delete/:id', protect, restrictTo('ADMIN'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const existing = await prisma.faq.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!existing) { + const error: CustomError = new Error('FAQ tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + await prisma.faq.delete({ + where: { id: parseInt(id, 10) } + }); + + res.status(200).json({ + status: true, + message: 'FAQ berhasil dihapus' + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/routes/feedback.ts b/src/routes/feedback.ts new file mode 100644 index 0000000..9c8f5b9 --- /dev/null +++ b/src/routes/feedback.ts @@ -0,0 +1,206 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { protect, restrictTo, AuthenticatedRequest } from '../middleware/auth'; +import prisma from '../lib/prisma'; +import { upload } from '../middleware/upload'; +import { CustomError } from '../middleware/errorHandler'; + +const router = Router(); + +// Map data feedback dari DB ke format frontend +function mapToFrontend(feedback: any) { + return { + id: feedback.id, + nama: feedback.nama, + universitas: feedback.universitas, + posisi: feedback.posisi, + batch: feedback.batch, + tahun: feedback.tahun, + pesan: feedback.pesan, + image_path: feedback.imagePath, + createdAt: feedback.createdAt + }; +} + +/** + * @route GET /api/feedback-api/get + * @desc Dapetin semua feedback dari mahasiswa + * @access Public + */ +router.get('/get', async (_req: Request, res: Response, next: NextFunction) => { + try { + const feedbacks = await prisma.feedback.findMany({ + orderBy: { createdAt: 'desc' } + }); + res.status(200).json({ + status: true, + data: feedbacks.map(mapToFrontend) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route GET /api/feedback-api/get/:id + * @desc Dapetin detail satu feedback + * @access Public + */ +router.get('/get/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const feedback = await prisma.feedback.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!feedback) { + const error: CustomError = new Error('Feedback tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + res.status(200).json({ + status: true, + data: mapToFrontend(feedback) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route POST /api/feedback-api/add + * @desc Tambah feedback baru (bisa admin / student) + * @access Private (Admin/Student) + */ +router.post('/add', protect, upload.single('image'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { nama, universitas, posisi, batch, tahun, pesan } = req.body; + + if (!nama || !universitas || !posisi || !batch || !tahun || !pesan) { + const error: CustomError = new Error('Mohon lengkapi semua field yang wajib diisi'); + error.statusCode = 400; + return next(error); + } + + const parsedBatch = parseInt(batch, 10); + const parsedTahun = parseInt(tahun, 10); + + if (isNaN(parsedBatch) || isNaN(parsedTahun)) { + const error: CustomError = new Error('Batch dan tahun harus berupa angka'); + error.statusCode = 400; + return next(error); + } + + const imagePath = req.file ? `/uploads/images/${req.file.filename}` : ''; + + const feedback = await prisma.feedback.create({ + data: { + nama, + universitas, + posisi, + batch: parsedBatch, + tahun: parsedTahun, + pesan, + imagePath + } + }); + + res.status(201).json({ + status: true, + message: 'Feedback berhasil ditambahkan', + data: mapToFrontend(feedback) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route PATCH /api/feedback-api/update/:id + * @desc Update data feedback (bisa admin / student) + * @access Private (Admin/Student) + */ +router.patch('/update/:id', protect, upload.single('image'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const { nama, universitas, posisi, batch, tahun, pesan } = req.body; + + const existing = await prisma.feedback.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!existing) { + const error: CustomError = new Error('Feedback tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + let imagePath = existing.imagePath; + if (req.file) { + imagePath = `/uploads/images/${req.file.filename}`; + } + + const parsedBatch = batch ? parseInt(batch, 10) : undefined; + const parsedTahun = tahun ? parseInt(tahun, 10) : undefined; + + if ((batch && isNaN(parsedBatch as number)) || (tahun && isNaN(parsedTahun as number))) { + const error: CustomError = new Error('Batch dan tahun harus berupa angka'); + error.statusCode = 400; + return next(error); + } + + const updated = await prisma.feedback.update({ + where: { id: parseInt(id, 10) }, + data: { + nama: nama ?? existing.nama, + universitas: universitas ?? existing.universitas, + posisi: posisi ?? existing.posisi, + batch: parsedBatch ?? existing.batch, + tahun: parsedTahun ?? existing.tahun, + pesan: pesan ?? existing.pesan, + imagePath + } + }); + + res.status(200).json({ + status: true, + message: 'Feedback berhasil diupdate', + data: mapToFrontend(updated) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route DELETE /api/feedback-api/delete/:id + * @desc Hapus feedback (khusus admin) + * @access Private (Admin) + */ +router.delete('/delete/:id', protect, restrictTo('ADMIN'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const existing = await prisma.feedback.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!existing) { + const error: CustomError = new Error('Feedback tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + await prisma.feedback.delete({ + where: { id: parseInt(id, 10) } + }); + + res.status(200).json({ + status: true, + message: 'Feedback berhasil dihapus' + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 5374951..6941a6f 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,10 +1,23 @@ import { Router, Request, Response, NextFunction } from 'express'; import prisma from '../lib/prisma'; import authRouter from './auth'; +import lowonganRouter from './lowongan'; +import lamaranRouter from './lamaran'; +import partnershipRouter from './partnership'; +import researchRouter from './research'; +import feedbackRouter from './feedback'; +import faqRouter from './faq'; const router = Router(); router.use('/auth', authRouter); +router.use('/auth-api', authRouter); +router.use('/lowongan-magang-api', lowonganRouter); +router.use('/lamaran-magang-api', lamaranRouter); +router.use('/partnership-api', partnershipRouter); +router.use('/hasil-research-api', researchRouter); +router.use('/feedback-api', feedbackRouter); +router.use('/faq-api', faqRouter); /** * @route GET /api/health diff --git a/src/routes/lamaran.ts b/src/routes/lamaran.ts new file mode 100644 index 0000000..4bbbeef --- /dev/null +++ b/src/routes/lamaran.ts @@ -0,0 +1,344 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import bcrypt from 'bcryptjs'; +import * as XLSX from 'xlsx'; +import { protect, restrictTo, AuthenticatedRequest } from '../middleware/auth'; +import prisma from '../lib/prisma'; +import { upload } from '../middleware/upload'; +import { CustomError } from '../middleware/errorHandler'; + +const router = Router(); + +// Map data lamaran dari DB ke format frontend +function mapToFrontend(lamaran: any) { + return { + id: lamaran.id, + status: (lamaran.status || 'diproses').toLowerCase(), + batch: lamaran.batch, + createdAt: lamaran.createdAt, + updatedAt: lamaran.updatedAt, + mahasiswa: lamaran.mahasiswa ? { + id: lamaran.mahasiswa.id, + nama_depan: lamaran.mahasiswa.namaDepan, + nama_belakang: lamaran.mahasiswa.namaBelakang || '', + email: lamaran.mahasiswa.user?.email || '', + kontak: lamaran.mahasiswa.kontak, + jurusan: lamaran.mahasiswa.jurusan, + universitas: lamaran.mahasiswa.universitas, + negara: lamaran.mahasiswa.negara, + cv_path: lamaran.mahasiswa.cvPath, + portofolio_path: lamaran.mahasiswa.portofolioPath, + motivasi: lamaran.mahasiswa.motivasi, + relevant_skills: lamaran.mahasiswa.relevantSkills + } : null, + lowongan_magang: lamaran.lowonganMagang ? { + id: lamaran.lowonganMagang.id, + posisi: lamaran.lowonganMagang.posisi, + kelompok_peminatan: lamaran.lowonganMagang.kelompokPeminatan + } : null + }; +} + +/** + * @route POST /api/lamaran-magang-api/add/:lowonganId + * @desc Kirim pendaftaran magang baru + * @access Public + */ +router.post( + '/add/:lowonganId', + upload.fields([ + { name: 'cv', maxCount: 1 }, + { name: 'portofolio', maxCount: 1 } + ]), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { lowonganId } = req.params; + const { + nama_depan, + nama_belakang, + email, + kontak, + universitas, + negara, + jurusan, + batch, + motivasi, + relevant_skills + } = req.body; + + if (!nama_depan || !email || !kontak || !universitas || !negara || !jurusan || !motivasi) { + const error: CustomError = new Error('Mohon lengkapi semua field wajib yang diperlukan'); + error.statusCode = 400; + return next(error); + } + + const files = req.files as { [fieldname: string]: Express.Multer.File[] }; + const cvFile = files?.['cv']?.[0]; + const portofolioFile = files?.['portofolio']?.[0]; + + if (!cvFile || !portofolioFile) { + const error: CustomError = new Error('CV dan Portofolio wajib diunggah!'); + error.statusCode = 400; + return next(error); + } + + const lowongan = await prisma.lowonganMagang.findUnique({ + where: { id: lowonganId } + }); + + if (!lowongan) { + const error: CustomError = new Error('Lowongan magang tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + let user = await prisma.user.findUnique({ + where: { email }, + include: { mahasiswa: true } + }); + + const cvPath = `/uploads/docs/${cvFile.filename}`; + const portofolioPath = `/uploads/docs/${portofolioFile.filename}`; + + if (!user) { + // Kalo user belom terdaftar, kita buatin akun STUDENT baru + const randomPassword = Math.random().toString(36).substring(2, 15); + const salt = await bcrypt.genSalt(10); + const passwordHash = await bcrypt.hash(randomPassword, salt); + + user = await prisma.user.create({ + data: { + email, + passwordHash, + role: 'STUDENT', + mahasiswa: { + create: { + namaDepan: nama_depan, + namaBelakang: nama_belakang || null, + kontak, + jurusan, + universitas, + negara, + cvPath, + portofolioPath, + motivasi, + relevantSkills: relevant_skills || '' + } + } + }, + include: { mahasiswa: true } + }); + } else { + // Kalo user udah ada, kita update data profil mahasiswanya aja + if (user.mahasiswa) { + await prisma.mahasiswa.update({ + where: { id: user.mahasiswa.id }, + data: { + namaDepan: nama_depan, + namaBelakang: nama_belakang || user.mahasiswa.namaBelakang, + kontak: kontak || user.mahasiswa.kontak, + jurusan: jurusan || user.mahasiswa.jurusan, + universitas: universitas || user.mahasiswa.universitas, + negara: negara || user.mahasiswa.negara, + cvPath, + portofolioPath, + motivasi, + relevantSkills: relevant_skills || user.mahasiswa.relevantSkills + } + }); + } + } + + const parsedBatch = batch ? parseInt(batch, 10) : lowongan.idBatch; + + const lamaran = await prisma.lamaranMagang.create({ + data: { + idMahasiswa: user.mahasiswa!.id, + idLowonganMagang: lowongan.id, + status: 'DIPROSES', + batch: parsedBatch + }, + include: { + mahasiswa: { + include: { user: true } + }, + lowonganMagang: true + } + }); + + res.status(201).json({ + status: true, + message: 'Pendaftaran magang berhasil dikirim', + data: mapToFrontend(lamaran) + }); + } catch (error) { + next(error); + } + } +); + +/** + * @route GET /api/lamaran-magang-api/get + * @desc Dapetin semua data pelamar (admin aja ya) + * @access Private (Admin) + */ +router.get('/get', protect, restrictTo('ADMIN'), async (_req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const applications = await prisma.lamaranMagang.findMany({ + include: { + mahasiswa: { + include: { user: true } + }, + lowonganMagang: true + }, + orderBy: { createdAt: 'desc' } + }); + res.status(200).json({ + status: true, + data: applications.map(mapToFrontend) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route GET /api/lamaran-magang-api/get/:id + * @desc Dapetin detail satu lamaran berdasarkan id + * @access Private (Admin) + */ +router.get('/get/:id', protect, restrictTo('ADMIN'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const application = await prisma.lamaranMagang.findUnique({ + where: { id: parseInt(id, 10) }, + include: { + mahasiswa: { + include: { user: true } + }, + lowonganMagang: true + } + }); + + if (!application) { + const error: CustomError = new Error('Lamaran tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + res.status(200).json({ + status: true, + data: mapToFrontend(application) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route PATCH /api/lamaran-magang-api/update/:id + * @desc Ganti status lamaran (diterima, ditolak, dll) + * @access Private (Admin) + */ +router.patch('/update/:id', protect, restrictTo('ADMIN'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const { status } = req.body; + + if (!status) { + const error: CustomError = new Error('Mohon masukkan status baru'); + error.statusCode = 400; + return next(error); + } + + const formattedStatus = status.toUpperCase(); + if (formattedStatus !== 'DITERIMA' && formattedStatus !== 'DITOLAK' && formattedStatus !== 'DIPROSES') { + const error: CustomError = new Error('Status tidak valid'); + error.statusCode = 400; + return next(error); + } + + const updated = await prisma.lamaranMagang.update({ + where: { id: parseInt(id, 10) }, + data: { + status: formattedStatus as any + }, + include: { + mahasiswa: { + include: { user: true } + }, + lowonganMagang: true + } + }); + + res.status(200).json({ + status: true, + message: 'Status lamaran berhasil diupdate', + data: mapToFrontend(updated) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route DELETE /api/lamaran-magang-api/delete + * @desc Hapus semua data pelamar + * @access Private (Admin) + */ +router.delete('/delete', protect, restrictTo('ADMIN'), async (_req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + await prisma.lamaranMagang.deleteMany({}); + res.status(200).json({ + status: true, + message: 'Seluruh data pelamar berhasil dihapus' + }); + } catch (error) { + next(error); + } +}); + +/** + * @route GET /api/lamaran-magang-api/export + * @desc Ekspor data pelamar ke file Excel (.xlsx) + * @access Private (Admin) + */ +router.get('/export', protect, restrictTo('ADMIN'), async (_req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const applications = await prisma.lamaranMagang.findMany({ + include: { + mahasiswa: { + include: { user: true } + }, + lowonganMagang: true + } + }); + + const rows = applications.map((app) => ({ + ID: app.id, + Nama: `${app.mahasiswa?.namaDepan} ${app.mahasiswa?.namaBelakang || ''}`.trim(), + Email: app.mahasiswa?.user?.email || '', + Kontak: app.mahasiswa?.kontak || '', + Universitas: app.mahasiswa?.universitas || '', + Jurusan: app.mahasiswa?.jurusan || '', + Negara: app.mahasiswa?.negara || '', + Posisi: app.lowonganMagang?.posisi || '', + Status: app.status, + Motivasi: app.mahasiswa?.motivasi || '', + Skills: app.mahasiswa?.relevantSkills || '' + })); + + const worksheet = XLSX.utils.json_to_sheet(rows); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Applications'); + + const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', 'attachment; filename="data.xlsx"'); + res.send(buffer); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/routes/lowongan.ts b/src/routes/lowongan.ts new file mode 100644 index 0000000..a852ad4 --- /dev/null +++ b/src/routes/lowongan.ts @@ -0,0 +1,262 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { protect, restrictTo, AuthenticatedRequest } from '../middleware/auth'; +import prisma from '../lib/prisma'; +import { upload } from '../middleware/upload'; +import { CustomError } from '../middleware/errorHandler'; + +const router = Router(); + +// Nyari batch yg aktif, kalo ga ada ya kita bikinin yg baru +async function getOrCreateActiveBatch() { + let batch = await prisma.batch.findFirst({ + where: { isActive: true } + }); + if (!batch) { + batch = await prisma.batch.create({ + data: { + batchNumber: 1, + isActive: true + } + }); + } + return batch; +} + +function mapToFrontend(lowongan: any) { + return { + id: lowongan.id, + id_batch: lowongan.idBatch, + posisi: lowongan.posisi, + kelompok_peminatan: lowongan.kelompokPeminatan, + image_path: lowongan.imagePath, + jobdesk: lowongan.jobdesk, + lokasi: lowongan.lokasi, + kualifikasi: lowongan.kualifikasi, + benefit: lowongan.benefit, + durasi_awal: lowongan.durasiAwal, + durasi_akhir: lowongan.durasiAkhir, + status_lowongan: lowongan.statusLowongan, + paid: lowongan.paid, + createdAt: lowongan.createdAt + }; +} + +/** + * @route GET /api/lowongan-magang-api/get + * @desc Ambil semua daftar lowongan magang nih + * @access Public + */ +router.get('/get', async (_req: Request, res: Response, next: NextFunction) => { + try { + const lowongans = await prisma.lowonganMagang.findMany({ + orderBy: { createdAt: 'desc' } + }); + res.status(200).json({ + status: true, + data: lowongans.map(mapToFrontend) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route GET /api/lowongan-magang-api/get/kelompok-all + * @desc Ambil semua kelompok peminatan yg unik + * @access Public + */ +router.get('/get/kelompok-all', async (_req: Request, res: Response, next: NextFunction) => { + try { + const result = await prisma.lowonganMagang.findMany({ + select: { kelompokPeminatan: true }, + distinct: ['kelompokPeminatan'] + }); + const categories = result.map((r) => r.kelompokPeminatan); + res.status(200).json({ + status: true, + data: categories + }); + } catch (error) { + next(error); + } +}); + +/** + * @route GET /api/lowongan-magang-api/get/id/:id + * @desc Ambil detail lowongan magang berdasarkan id + * @access Public + */ +router.get('/get/id/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const lowongan = await prisma.lowonganMagang.findUnique({ + where: { id } + }); + if (!lowongan) { + const error: CustomError = new Error('Lowongan tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + res.status(200).json({ + status: true, + data: mapToFrontend(lowongan) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route POST /api/lowongan-magang-api/add + * @desc Tambah lowongan magang baru (khusus admin ya) + * @access Private (Admin) + */ +router.post('/add', protect, restrictTo('ADMIN'), upload.single('image'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { + posisi, + kelompok_peminatan, + lokasi, + jobdesk, + kualifikasi, + benefit, + durasi_awal, + durasi_akhir, + paid + } = req.body; + + if (!posisi || !kelompok_peminatan || !lokasi || !jobdesk || !durasi_awal || !durasi_akhir || !paid) { + const error: CustomError = new Error('Mohon lengkapi semua field yang wajib diisi'); + error.statusCode = 400; + return next(error); + } + + const batch = await getOrCreateActiveBatch(); + + const imagePath = req.file ? `/uploads/images/${req.file.filename}` : ''; + + const id = `lowongan-${Date.now()}`; + + const lowongan = await prisma.lowonganMagang.create({ + data: { + id, + idBatch: batch.id, + posisi, + kelompokPeminatan: kelompok_peminatan, + imagePath, + jobdesk, + lokasi, + kualifikasi: kualifikasi || '', + benefit: benefit || '', + durasiAwal: new Date(durasi_awal), + durasiAkhir: new Date(durasi_akhir), + statusLowongan: 'DIBUKA', + paid: paid.toUpperCase() === 'PAID' ? 'PAID' : 'UNPAID' + } + }); + + res.status(201).json({ + status: true, + message: 'Internship lowongan berhasil ditambahkan', + data: mapToFrontend(lowongan) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route PATCH /api/lowongan-magang-api/update/:id + * @desc Update data lowongan magang (khusus admin) + * @access Private (Admin) + */ +router.patch('/update/:id', protect, restrictTo('ADMIN'), upload.single('image'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const { + posisi, + kelompok_peminatan, + lokasi, + jobdesk, + kualifikasi, + benefit, + durasi_awal, + durasi_akhir, + paid, + status_lowongan + } = req.body; + + const existing = await prisma.lowonganMagang.findUnique({ + where: { id } + }); + + if (!existing) { + const error: CustomError = new Error('Lowongan tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + let imagePath = existing.imagePath; + if (req.file) { + imagePath = `/uploads/images/${req.file.filename}`; + } + + const updated = await prisma.lowonganMagang.update({ + where: { id }, + data: { + posisi: posisi ?? existing.posisi, + kelompokPeminatan: kelompok_peminatan ?? existing.kelompokPeminatan, + lokasi: lokasi ?? existing.lokasi, + jobdesk: jobdesk ?? existing.jobdesk, + kualifikasi: kualifikasi ?? existing.kualifikasi, + benefit: benefit ?? existing.benefit, + durasiAwal: durasi_awal ? new Date(durasi_awal) : existing.durasiAwal, + durasiAkhir: durasi_akhir ? new Date(durasi_akhir) : existing.durasiAkhir, + statusLowongan: (status_lowongan ?? existing.statusLowongan) === 'DIBUKA' ? 'DIBUKA' : 'DITUTUP', + paid: paid ? (paid.toUpperCase() === 'PAID' ? 'PAID' : 'UNPAID') : existing.paid, + imagePath + } + }); + + res.status(200).json({ + status: true, + message: 'Lowongan berhasil diupdate', + data: mapToFrontend(updated) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route DELETE /api/lowongan-magang-api/delete/:id + * @desc Hapus lowongan magang (khusus admin) + * @access Private (Admin) + */ +router.delete('/delete/:id', protect, restrictTo('ADMIN'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const existing = await prisma.lowonganMagang.findUnique({ + where: { id } + }); + + if (!existing) { + const error: CustomError = new Error('Lowongan tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + await prisma.lowonganMagang.delete({ + where: { id } + }); + + res.status(200).json({ + status: true, + message: 'Lowongan berhasil dihapus' + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/routes/partnership.ts b/src/routes/partnership.ts new file mode 100644 index 0000000..2cebd17 --- /dev/null +++ b/src/routes/partnership.ts @@ -0,0 +1,178 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { protect, restrictTo, AuthenticatedRequest } from '../middleware/auth'; +import prisma from '../lib/prisma'; +import { upload } from '../middleware/upload'; +import { CustomError } from '../middleware/errorHandler'; + +const router = Router(); + +// Map data partnership dari DB ke format frontend +function mapToFrontend(partner: any) { + return { + id: partner.id, + nama_partner: partner.namaPartner, + image_path: partner.imagePath, + createdAt: partner.createdAt + }; +} + +/** + * @route GET /api/partnership-api/get + * @desc Dapetin semua data partnerships + * @access Public + */ +router.get('/get', async (_req: Request, res: Response, next: NextFunction) => { + try { + const partners = await prisma.partnership.findMany({ + orderBy: { createdAt: 'desc' } + }); + res.status(200).json({ + status: true, + data: partners.map(mapToFrontend) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route GET /api/partnership-api/get/:id + * @desc Dapetin detail satu partnership + * @access Public + */ +router.get('/get/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const partner = await prisma.partnership.findFirst({ + where: { id: parseInt(id, 10) } + }); + + if (!partner) { + const error: CustomError = new Error('Partnership tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + res.status(200).json({ + status: true, + data: [mapToFrontend(partner)] // Kadang list/detail di frontend minta dibungkus array + }); + } catch (error) { + next(error); + } +}); + +/** + * @route POST /api/partnership-api/add + * @desc Tambah partnership baru (khusus admin ya) + * @access Private (Admin) + */ +router.post('/add', protect, restrictTo('ADMIN'), upload.single('image'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { nama_partner } = req.body; + if (!nama_partner) { + const error: CustomError = new Error('Nama partnership harus diisi'); + error.statusCode = 400; + return next(error); + } + + if (!req.file) { + const error: CustomError = new Error('Thumbnail/image harus diunggah'); + error.statusCode = 400; + return next(error); + } + + const imagePath = `/uploads/images/${req.file.filename}`; + + const partner = await prisma.partnership.create({ + data: { + namaPartner: nama_partner, + imagePath + } + }); + + res.status(201).json({ + status: true, + message: 'Partnership berhasil ditambahkan', + data: mapToFrontend(partner) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route PATCH /api/partnership-api/update/:id + * @desc Update data partnership (khusus admin) + * @access Private (Admin) + */ +router.patch('/update/:id', protect, restrictTo('ADMIN'), upload.single('image'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const { nama_partner } = req.body; + + const existing = await prisma.partnership.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!existing) { + const error: CustomError = new Error('Partnership tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + let imagePath = existing.imagePath; + if (req.file) { + imagePath = `/uploads/images/${req.file.filename}`; + } + + const updated = await prisma.partnership.update({ + where: { id: parseInt(id, 10) }, + data: { + namaPartner: nama_partner ?? existing.namaPartner, + imagePath + } + }); + + res.status(200).json({ + status: true, + message: 'Partnership berhasil diupdate', + data: mapToFrontend(updated) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route DELETE /api/partnership-api/delete/:id + * @desc Hapus data partnership (khusus admin) + * @access Private (Admin) + */ +router.delete('/delete/:id', protect, restrictTo('ADMIN'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const existing = await prisma.partnership.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!existing) { + const error: CustomError = new Error('Partnership tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + await prisma.partnership.delete({ + where: { id: parseInt(id, 10) } + }); + + res.status(200).json({ + status: true, + message: 'Partnership berhasil dihapus' + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/routes/research.ts b/src/routes/research.ts new file mode 100644 index 0000000..e4fa316 --- /dev/null +++ b/src/routes/research.ts @@ -0,0 +1,179 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { protect, restrictTo, AuthenticatedRequest } from '../middleware/auth'; +import prisma from '../lib/prisma'; +import { upload } from '../middleware/upload'; +import { CustomError } from '../middleware/errorHandler'; + +const router = Router(); + +// Map data research product dari DB ke format frontend +function mapToFrontend(product: any) { + return { + id: product.id, + nama_project: product.namaProject, + deskripsi: product.deskripsi, + link_project: product.linkProject, + image_path: product.imagePath, + createdAt: product.createdAt + }; +} + +/** + * @route GET /api/hasil-research-api/get + * @desc Dapetin semua hasil riset/products + * @access Public + */ +router.get('/get', async (_req: Request, res: Response, next: NextFunction) => { + try { + const products = await prisma.hasilResearch.findMany({ + orderBy: { createdAt: 'desc' } + }); + res.status(200).json({ + status: true, + data: products.map(mapToFrontend) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route GET /api/hasil-research-api/get/:id + * @desc Dapetin detail satu hasil riset/product + * @access Public + */ +router.get('/get/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const product = await prisma.hasilResearch.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!product) { + const error: CustomError = new Error('Project tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + res.status(200).json({ + status: true, + data: mapToFrontend(product) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route POST /api/hasil-research-api/add + * @desc Tambah hasil riset baru (khusus admin ya) + * @access Private (Admin) + */ +router.post('/add', protect, restrictTo('ADMIN'), upload.single('image'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { nama_project, deskripsi, link_project } = req.body; + + if (!nama_project || !deskripsi || !link_project) { + const error: CustomError = new Error('Mohon lengkapi semua field yang wajib diisi'); + error.statusCode = 400; + return next(error); + } + + const imagePath = req.file ? `/uploads/images/${req.file.filename}` : ''; + + const product = await prisma.hasilResearch.create({ + data: { + namaProject: nama_project, + deskripsi, + linkProject: link_project, + imagePath + } + }); + + res.status(201).json({ + status: true, + message: 'Research project berhasil ditambahkan', + data: mapToFrontend(product) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route PATCH /api/hasil-research-api/update/:id + * @desc Update data hasil riset (khusus admin) + * @access Private (Admin) + */ +router.patch('/update/:id', protect, restrictTo('ADMIN'), upload.single('image'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const { nama_project, deskripsi, link_project } = req.body; + + const existing = await prisma.hasilResearch.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!existing) { + const error: CustomError = new Error('Project tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + let imagePath = existing.imagePath; + if (req.file) { + imagePath = `/uploads/images/${req.file.filename}`; + } + + const updated = await prisma.hasilResearch.update({ + where: { id: parseInt(id, 10) }, + data: { + namaProject: nama_project ?? existing.namaProject, + deskripsi: deskripsi ?? existing.deskripsi, + linkProject: link_project ?? existing.linkProject, + imagePath + } + }); + + res.status(200).json({ + status: true, + message: 'Research project berhasil diupdate', + data: mapToFrontend(updated) + }); + } catch (error) { + next(error); + } +}); + +/** + * @route DELETE /api/hasil-research-api/delete/:id + * @desc Hapus data hasil riset (khusus admin) + * @access Private (Admin) + */ +router.delete('/delete/:id', protect, restrictTo('ADMIN'), async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const existing = await prisma.hasilResearch.findUnique({ + where: { id: parseInt(id, 10) } + }); + + if (!existing) { + const error: CustomError = new Error('Project tidak ditemukan'); + error.statusCode = 404; + return next(error); + } + + await prisma.hasilResearch.delete({ + where: { id: parseInt(id, 10) } + }); + + res.status(200).json({ + status: true, + message: 'Research project berhasil dihapus' + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/uploads/docs/cv-1780226578695-76608645.pdf b/uploads/docs/cv-1780226578695-76608645.pdf new file mode 100644 index 0000000..f43b717 --- /dev/null +++ b/uploads/docs/cv-1780226578695-76608645.pdf @@ -0,0 +1 @@ +%PDF-1.4 mock cv \ No newline at end of file diff --git a/uploads/docs/portofolio-1780226578696-235536074.pdf b/uploads/docs/portofolio-1780226578696-235536074.pdf new file mode 100644 index 0000000..ccb4686 --- /dev/null +++ b/uploads/docs/portofolio-1780226578696-235536074.pdf @@ -0,0 +1 @@ +%PDF-1.4 mock portfolio \ No newline at end of file diff --git a/uploads/images/image-1780226578634-893133582.png b/uploads/images/image-1780226578634-893133582.png new file mode 100644 index 0000000..c9d83be --- /dev/null +++ b/uploads/images/image-1780226578634-893133582.png @@ -0,0 +1 @@ +fake-image-content \ No newline at end of file