{"repo": "azrahudaya/bekal10", "files": {"server.js": {"09a03243bef9c6fefc85dc062cde83eec1c78a4f": {"t": 1785745542.1902459, "n": 32663, "h": "bf987a75ac0ec1d2", "c": "const http = require(\"http\");\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst tls = require(\"tls\");\n\nloadEnv();\n\nconst PORT = Number(process.env.PORT || 3000);\nconst PUBLIC_DIR = process.cwd();\nconst DEEPSEEK_BASE_URL = (process.env.DEEPSEEK_BASE_URL || \"https://api.deepseek.com\").replace(/\\/$/, \"\");\nconst DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || \"deepseek-v4-flash\";\n\nconst mimeTypes = {\n \".html\": \"text/html; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".json\": \"application/json; charset=utf-8\",\n};\n\nconst server = http.createServer(async (req, res) => {\n try {\n if (req.method === \"POST\" && req.url === \"/api/chat\") {\n await handleChat(req, res);\n return;\n }\n\n if (req.method === \"POST\" && req.url === \"/api/send-results\") {\n await handleSendResults(req, res);\n return;\n }\n\n if (req.method === \"GET\") {\n serveStatic(req, res);\n return;\n }\n\n sendJson(res, 405, { message: \"Method tidak didukung.\" });\n } catch (error) {\n sendJson(res, 500, { message: \"Server error.\" });\n }\n});\n\nserver.listen(PORT, () => {\n console.log(`Bekal10 jalan di http://localhost:${PORT}`);\n});\n\nfunction loadEnv() {\n const envPath = path.join(process.cwd(), \".env\");\n if (!fs.existsSync(envPath)) return;\n\n const lines = fs.readFileSync(envPath, \"utf8\").split(/\\r?\\n/);\n lines.forEach((line) => {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) return;\n\n const separator = trimmed.indexOf(\"=\");\n if (separator === -1) return;\n\n const key = trimmed.slice(0, separator).trim();\n const value = trimmed.slice(separator + 1).trim().replace(/^[\"']|[\"']$/g, \"\");\n if (!process.env[key]) process.env[key] = value;\n });\n}\n\nfunction serveStatic(req, res) {\n const urlPath = req.url === \"/\" ? \"/index.html\" : decodeURIComponent(req.url.split(\"?\")[0]);\n const filePath = path.normalize(path.join(PUBLIC_DIR, urlPath));\n const fileName = path.basename(filePath);\n\n if (!filePath.startsWith(PUBLIC_DIR) || fileName === \".env\") {\n res.writeHead(403);\n res.end(\"Forbidden\");\n return;\n }\n\n fs.readFile(filePath, (error, content) => {\n if (error) {\n res.writeHead(404);\n res.end(\"Not found\");\n return;\n }\n\n res.writeHead(200, {\n \"Content-Type\": mimeTypes[path.extname(filePath)] || \"application/octet-stream\",\n });\n res.end(content);\n });\n}\n\nasync function handleChat(req, res) {\n if (!process.env.DEEPSEEK_API_KEY) {\n sendJson(res, 500, { message: \"DEEPSEEK_API_KEY belum ada di .env.\" });\n return;\n }\n\n const body = await readJson(req);\n const apiResponse = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n model: DEEPSEEK_MODEL,\n messages: buildChatMessages(body),\n temperature: 0.2,\n max_tokens: 1200,\n stream: false,\n user_id: sanitizeUserId(body.participant?.id || \"bekal10-user\"),\n }),\n });\n\n const data = await apiResponse.json().catch(() => ({}));\n\n if (!apiResponse.ok) {\n sendJson(res, apiResponse.status, {\n message: data.error?.message || \"Gagal memanggil DeepSeek.\",\n });\n return;\n }\n\n const reply = cleanReply(getDeepSeekReply(data)) || buildFallbackReply(body);\n sendJson(res, 200, { reply });\n}\n\nasync function handleSendResults(req, res) {\n const body = await readJson(req);\n const savedPath = saveResultJson(body);\n const userEmailResult = await sendUserAttemptEmail(body);\n const adminEmailResult = await sendAdminResultsEmail(body);\n\n sendJson(res, 200, {\n ok: true,\n saved: true,\n emailSent: adminEmailResult.sent,\n adminEmailSent: adminEmailResult.sent,\n userEmailSent: userEmailResult.sent,\n jsonFile: savedPath,\n message: buildSendResultsMessage(userEmailResult, adminEmailResult),\n });\n}\n\nasync function sendUserAttemptEmail(body) {\n const participant = body.participant || {};\n const attempt = body.attempt || {};\n const recipient = String(participant.email || \"\").trim();\n const testLabel = attempt.testLabel || \"tes\";\n\n if (!attempt.testId || !attempt.scores) {\n return { sent: false, skipped: true, reason: \"NO_ATTEMPT\", message: \"Tidak ada hasil tes baru untuk email peserta.\" };\n }\n\n if (!isValidEmailAddress(recipient)) {\n return { sent: false, skipped: true, reason: \"NO_USER_EMAIL\", message: \"Email peserta belum valid.\" };\n }\n\n const smtpConfig = getSmtpConfig([recipient]);\n if (!smtpConfig.ready) {\n return { sent: false, skipped: true, reason: \"SMTP_MISSING\", message: \"SMTP belum lengkap, email peserta belum terkirim.\" };\n }\n\n const email = await buildUserResultsEmail(body);\n try {\n await sendSmtpMail({\n ...smtpConfig,\n subject: `Hasil ${testLabel} Bekal10`,\n text: email.text,\n html: email.html,\n });\n\n return { sent: true, skipped: false, reason: \"SENT\", message: `Email hasil ${testLabel} terkirim ke peserta.` };\n } catch (error) {\n return { sent: false, skipped: false, reason: \"USER_EMAIL_FAILED\", message: `Email hasil ${testLabel} gagal dikirim ke peserta.` };\n }\n}\n\nasync function sendAdminResultsEmail(body) {\n const riasec = body.riasec;\n const vark = body.vark;\n\n if (!riasec || !vark) {\n return { sent: false, skipped: true, reason: \"WAITING_FOR_BOTH_TESTS\", message: \"Rekap admin dikirim setelah RIASEC dan VARK selesai.\" };\n }\n\n const fingerprint = `${riasec.id}:${vark.id}`;\n if (body.participant?.emailSentFingerprint === fingerprint) {\n return { sent: false, skipped: true, reason: \"ADMIN_ALREADY_SENT\", message: \"Rekap admin sudah pernah dikirim.\" };\n }\n\n const smtpConfig = getSmtpConfig();\n if (!smtpConfig.ready) {\n return { sent: false, skipped: true, reason: \"SMTP_MISSING\", message: \"SMTP belum lengkap, rekap admin belum terkirim.\" };\n }\n\n const email = buildResultsEmail(body);\n try {\n await sendSmtpMail({\n ...smtpConfig,\n subject: `Hasil Tes Bekal10 ${body.participant?.name || \"Peserta\"}`,\n text: email.text,\n html: email.html,\n });\n\n return { sent: true, skipped: false, reason: \"SENT\", message: \"Rekap admin terkirim.\" };\n } catch (error) {\n return { sent: false, skipped: false, reason: \"ADMIN_EMAIL_FAILED\", message: \"Rekap admin gagal dikirim.\" };\n }\n}\n\nfunction buildSendResultsMessage(userEmailResult, adminEmailResult) {\n return [\n \"JSON tersimpan.\",\n userEmailResult.message,\n adminEmailResult.message,\n ].filter(Boolean).join(\" \");\n}\n\nfunction buildChatMessages(body) {\n const scope = body.testId === \"vark\"\n ? \"VARK, gaya belajar, hasil skor VARK, saran belajar, rekomendasi jurusan/prodi/kuliah/kampus, dan hubungan gaya belajar dengan pilihan studi. Jika hasil RIASEC tersedia, gunakan sebagai dasar utama.\"\n : \"RIASEC, minat karier, hasil skor RIASEC, saran jurusan/prodi/kuliah/kampus, arah karier, dan cara mengembangkan minat.\";\n\n const scores = body.result?.scores\n ? Object.entries(body.result.scores)\n .map(([key, value]) => `${body.traits?.[key] || key}: ${value}`)\n .join(\", \")\n : \"belum ada\";\n const riasecScores = formatScores(body.latestResults?.riasec?.scores, body.labels?.riasec);\n const varkScores = formatScores(body.latestResults?.vark?.scores, body.labels?.vark);\n\n const system = [\n \"Kamu adalah chatbot Bekal10.\",\n \"Jawab dalam Bahasa Indonesia.\",\n \"Jawaban harus rapi, jelas, dan tidak terlalu panjang.\",\n \"Jangan menulis semuanya dalam satu paragraf panjang.\",\n \"Gunakan maksimal 2 paragraf pendek dan maksimal 5 poin list.\",\n \"Selesaikan jawaban secara utuh, jangan berhenti di tengah kalimat.\",\n \"Gunakan markdown sederhana untuk penekanan, misalnya **Social (21)**.\",\n `Topik yang boleh dibahas hanya: ${scope}`,\n \"Pertanyaan tentang jurusan, prodi, kuliah, kampus, SMK/SMA lanjut, pekerjaan, portofolio, dan cara belajar masih termasuk topik.\",\n \"Kalau pertanyaan benar-benar di luar topik pendidikan/tes, jawab singkat: Maaf, aku hanya bisa membahas hasil tes dan rencana pendidikan/karier.\",\n \"Kalau user bertanya jurusan/prodi/kuliah/kampus, berikan rekomendasi praktis. Gunakan RIASEC sebagai dasar utama dan VARK sebagai pendukung cara belajar.\",\n `Nama peserta: ${body.participant?.name || \"-\"}.`,\n `Tes aktif: ${body.testLabel || body.testId}.`,\n `Skor tes aktif: ${scores}.`,\n `Skor RIASEC terbaru: ${riasecScores || \"belum ada\"}.`,\n `Skor VARK terbaru: ${varkScores || \"belum ada\"}.`,\n ].join(\"\\n\");\n\n const history = Array.isArray(body.history) ? body.history.slice(-8) : [];\n const mappedHistory = history\n .filter((item) => item.text && item.text !== \"Menjawab...\")\n .map((item) => ({\n role: item.sender === \"user\" ? \"user\" : \"assistant\",\n content: String(item.text).slice(0, 1800),\n }));\n\n return [\n { role: \"system\", content: system },\n ...mappedHistory,\n { role: \"user\", content: String(body.message || \"\").slice(0, 1800) },\n ];\n}\n\nfunction getDeepSeekReply(data) {\n return data.choices?.[0]?.message?.content\n || data.choices?.[0]?.delta?.content\n || data.choices?.[0]?.text\n || \"\";\n}\n\nfunction cleanReply(value) {\n const reply = String(value || \"\").trim();\n if (!reply) return \"\";\n if (/^tidak ada balasan\\.?$/i.test(reply)) return \"\";\n if (/^null$/i.test(reply)) return \"\";\n return reply;\n}\n\nfunction formatScores(scores = {}, labels = {}) {\n return Object.entries(scores)\n .sort((a, b) => b[1] - a[1])\n .map(([key, value]) => `${labels[key] || key}: ${value}`)\n .join(\", \");\n}\n\nfunction buildFallbackReply(body) {\n const message = String(body.message || \"\").toLowerCase();\n const riasecScores = body.latestResults?.riasec?.scores;\n const varkScores = body.latestResults?.vark?.scores || body.result?.scores;\n const riasecTop = topScores(riasecScores, body.labels?.riasec);\n const varkTop = topScores(varkScores, body.labels?.vark || body.traits);\n\n if (isEducationQuestion(message)) {\n if (riasecTop.length) {\n const recommendations = recommendMajorsByRiasec(riasecTop[0].key);\n return [\n `Berdasarkan hasilmu, tipe terkuat adalah **${riasecTop.map((item) => `${item.label} (${item.score})`).join(\"**, **\")}**.`,\n \"\",\n \"Rekomendasi jurusan awal:\",\n ...recommendations.map((item, index) => `${index + 1}. **${item}**`),\n \"\",\n varkTop.length\n ? `Hasil VARK kamu yang kuat: **${varkTop.map((item) => item.label).join(\", \")}**. Gunakan itu untuk memilih cara belajar, bukan sebagai satu-satunya dasar memilih jurusan.`\n : \"Untuk lebih akurat, selesaikan juga Tes VARK.\",\n ].join(\"\\n\");\n }\n\n if (varkTop.length) {\n return [\n `Dari VARK, gaya belajar kuat kamu adalah **${varkTop.map((item) => `${item.label} (${item.score})`).join(\"**, **\")}**.`,\n \"\",\n \"VARK bukan tes minat jurusan, tapi bisa jadi pendukung. Jurusan yang banyak cocok dengan gaya belajar praktik/diskusi antara lain:\",\n \"1. **Pendidikan**\",\n \"2. **Komunikasi**\",\n \"3. **Keperawatan**\",\n \"4. **Pariwisata**\",\n \"5. **Desain atau bidang praktikum**\",\n \"\",\n \"Untuk rekomendasi jurusan yang lebih tepat, selesaikan juga Tes RIASEC.\",\n ].join(\"\\n\");\n }\n }\n\n if (body.testId === \"vark\") {\n return [\n `Hasil VARK kamu yang menonjol: **${varkTop.map((item) => `${item.label} (${item.score})`).join(\"**, **\") || \"belum ada\"}**.`,\n \"\",\n \"Gunakan hasil ini untuk memilih cara belajar yang paling nyaman.\",\n \"Kalau mau rekomendasi jurusan, hasil RIASEC lebih tepat dijadikan dasar utama.\",\n ].join(\"\\n\");\n }\n\n return [\n `Hasil RIASEC kamu yang menonjol: **${riasecTop.map((item) => `${item.label} (${item.score})`).join(\"**, **\") || \"belum ada\"}**.`,\n \"\",\n \"Kamu bisa tanya rekomendasi jurusan, karier, atau cara mengembangkan minat dari hasil ini.\",\n ].join(\"\\n\");\n}\n\nfunction isEducationQuestion(message) {\n return [\n \"jurusan\",\n \"prodi\",\n \"kuliah\",\n \"kampus\",\n \"universitas\",\n \"politeknik\",\n \"sekolah\",\n \"smk\",\n \"sma\",\n \"karier\",\n \"kerja\",\n \"pekerjaan\",\n \"dimana\",\n \"di mana\",\n ].some((keyword) => message.includes(keyword));\n}\n\nfunction topScores(scores = {}, labels = {}) {\n const sorted = Object.entries(scores)\n .map(([key, score]) => ({ key, label: labels[key] || key, score }))\n .sort((a, b) => b.score - a.score);\n\n if (!sorted.length) return [];\n const topScore = sorted[0].score;\n return sorted.filter((item) => item.score === topScore);\n}\n\nfunction recommendMajorsByRiasec(trait) {\n const map = {\n realistic: [\"Teknik Mesin\", \"Teknik Elektro\", \"Teknik Sipil\", \"Agribisnis\", \"Desain Produk\"],\n investigative: [\"Kedokteran\", \"Farmasi\", \"Data Science\", \"Teknik Informatika\", \"Biologi\"],\n artistic: [\"Desain Komunikasi Visual\", \"Seni Rupa\", \"Film\", \"Sastra\", \"Arsitektur\"],\n social: [\"Pendidikan\", \"Bimbingan dan Konseling\", \"Psikologi\", \"Keperawatan\", \"Ilmu Komunikasi\"],\n enterprising: [\"Manajemen\", \"Bisnis Digital\", \"Marketing\", \"Ilmu Komunikasi\", \"Hukum\"],\n conventional: [\"Akuntansi\", \"Administrasi Bisnis\", \"Manajemen Perkantoran\", \"Sistem Informasi\", \"Perpajakan\"],\n };\n\n return map[trait] || [\"Manajemen\", \"Ilmu Komunikasi\", \"Sistem Informasi\", \"Pendidikan\", \"Bisnis Digital\"];\n}\n\nfunction getSmtpConfig(toOverride = null) {\n const user = process.env.SMTP_USER || process.env.EMAIL_USER;\n const pass = (process.env.SMTP_PASS || process.env.EMAIL_PASS || \"\").replace(/\\s+/g, \"\");\n const from = process.env.SMTP_FROM || user;\n const to = Array.isArray(toOverride)\n ? toOverride.filter(isValidEmailAddress)\n : splitEmails(process.env.SMTP_TO || process.env.EMAIL_TO);\n\n return {\n ready: Boolean(user && pass && from && to.length),\n host: process.env.SMTP_HOST || \"smtp.gmail.com\",\n port: Number(process.env.SMTP_PORT || 465),\n user,\n pass,\n from,\n to,\n };\n}\n\nfunction buildResultsEmail(body) {\n const participant = body.participant || {};\n const attempt = body.attempt || {};\n const riasecRows = scoreRows(body.riasec?.scores, body.labels?.riasec);\n const varkRows = scoreRows(body.vark?.scores, body.labels?.vark);\n\n const text = [\n \"Hasil Tes Bekal10\",\n \"\",\n `Tes selesai: ${attempt.testLabel || \"-\"}`,\n `Waktu submit: ${formatDate(attempt.createdAt)}`,\n \"\",\n `Nama: ${participant.name || \"-\"}`,\n `Kelas: ${participant.studentClass || \"-\"}`,\n `Umur: ${participant.age || \"-\"}`,\n `No. HP: ${participant.phone || \"-\"}`,\n `Email: ${participant.email || \"-\"}`,\n \"\",\n \"Hasil RIASEC:\",\n ...(riasecRows.length ? riasecRows.map((row) => `- ${row.label}: ${row.score}`) : [\"- Belum selesai\"]),\n \"\",\n \"Hasil VARK:\",\n ...(varkRows.length ? varkRows.map((row) => `- ${row.label}: ${row.score}`) : [\"- Belum selesai\"]),\n ].join(\"\\n\");\n\n const html = `\n
Tes selesai: ${escapeHtml(attempt.testLabel || \"-\")}
\nWaktu submit: ${escapeHtml(formatDate(attempt.createdAt))}
\n| Nama | ${escapeHtml(participant.name || \"-\")} |
| Kelas | ${escapeHtml(participant.studentClass || \"-\")} |
| Umur | ${escapeHtml(participant.age || \"-\")} |
| No. HP | ${escapeHtml(participant.phone || \"-\")} |
| ${escapeHtml(participant.email || \"-\")} |
Belum selesai.
\"}\nBelum selesai.
\"}\n `;\n\n return { text, html };\n}\n\nasync function buildUserResultsEmail(body) {\n const participant = body.participant || {};\n const attempt = body.attempt || {};\n const labels = body.labels?.[attempt.testId] || {};\n const rows = scoreRows(attempt.scores, labels);\n const topRows = getTopScoreRows(rows);\n const summary = await buildAiEmailSummary(body, rows);\n const testLabel = attempt.testLabel || \"Tes Bekal10\";\n\n const text = [\n `Hasil ${testLabel} Bekal10`,\n \"\",\n `Halo ${participant.name || \"Peserta\"},`,\n `Tes selesai: ${testLabel}`,\n `Waktu submit: ${formatDate(attempt.createdAt)}`,\n \"\",\n `Kategori tertinggi: ${topRows.map((row) => `${row.label} (${row.score})`).join(\", \") || \"-\"}`,\n \"\",\n \"Skor:\",\n ...(rows.length ? rows.map((row) => `- ${row.label}: ${row.score}`) : [\"- Belum ada skor\"]),\n \"\",\n \"Ringkasan:\",\n summary,\n ].join(\"\\n\");\n\n const html = buildUserResultsEmailHtml({\n participant,\n attempt,\n rows,\n summary,\n testLabel,\n topRows,\n });\n\n return { text, html };\n}\n\nasync function buildAiEmailSummary(body, rows) {\n const fallback = buildFallbackEmailSummary(body, rows);\n if (!process.env.DEEPSEEK_API_KEY) return fallback;\n\n try {\n const apiResponse = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n model: DEEPSEEK_MODEL,\n messages: buildEmailSummaryMessages(body, rows),\n temperature: 0.2,\n max_tokens: 550,\n stream: false,\n user_id: sanitizeUserId(body.participant?.id || \"bekal10-user\"),\n }),\n });\n\n const data = await apiResponse.json().catch(() => ({}));\n if (!apiResponse.ok) return fallback;\n\n return cleanReply(getDeepSeekReply(data)) || fallback;\n } catch (error) {\n return fallback;\n }\n}\n\nfunction buildEmailSummaryMessages(body, rows) {\n const participant = body.participant || {};\n const attempt = body.attempt || {};\n const labels = body.labels || {};\n const scoreText = rows.map((row) => `${row.label}: ${row.score}`).join(\", \");\n const riasecScores = formatScores(body.riasec?.scores, labels.riasec);\n const varkScores = formatScores(body.vark?.scores, labels.vark);\n\n return [\n {\n role: \"system\",\n content: [\n \"Kamu menulis ringkasan hasil tes Bekal10 untuk email peserta.\",\n \"Jawab dalam Bahasa Indonesia.\",\n \"Gunakan plain text saja, tanpa markdown tebal dan tanpa tabel.\",\n \"Tulis 1 paragraf pembuka pendek dan 3 bullet yang diawali tanda '-'.\",\n \"Fokus pada tes yang baru selesai. Jika hasil tes lain tersedia, sebutkan hanya sebagai konteks pendukung.\",\n \"Jangan memberi diagnosis psikologis, jangan membuat klaim pasti, dan jangan terlalu panjang.\",\n ].join(\"\\n\"),\n },\n {\n role: \"user\",\n content: [\n `Nama: ${participant.name || \"Peserta\"}`,\n `Kelas: ${participant.studentClass || \"-\"}`,\n `Umur: ${participant.age || \"-\"}`,\n `Tes yang baru selesai: ${attempt.testLabel || attempt.testId || \"-\"}`,\n `Skor tes ini: ${scoreText || \"belum ada\"}`,\n `Skor RIASEC terbaru: ${riasecScores || \"belum ada\"}`,\n `Skor VARK terbaru: ${varkScores || \"belum ada\"}`,\n ].join(\"\\n\"),\n },\n ];\n}\n\nfunction buildFallbackEmailSummary(body, rows) {\n const attempt = body.attempt || {};\n const topRows = getTopScoreRows(rows);\n const topText = topRows.map((row) => `${row.label} (${row.score})`).join(\", \") || \"belum terlihat jelas\";\n\n if (attempt.testId === \"vark\") {\n return [\n `Hasil VARK kamu paling menonjol pada ${topText}. Ini bisa dipakai untuk memilih cara belajar yang terasa paling nyaman.`,\n \"- Gunakan gaya belajar terkuat sebagai strategi utama saat memahami materi baru.\",\n \"- Kombinasikan dengan gaya belajar lain agar proses belajar tidak terlalu sempit.\",\n \"- Jika ingin memilih jurusan atau karier, jadikan hasil RIASEC sebagai dasar utama dan VARK sebagai pendukung cara belajar.\",\n ].join(\"\\n\");\n }\n\n const recommendations = topRows.length ? recommendMajorsByRiasec(topRows[0].key) : [];\n return [\n `Hasil RIASEC kamu paling menonjol pada ${topText}. Ini menunjukkan area minat yang bisa mulai kamu eksplorasi lebih serius.`,\n \"- Perhatikan aktivitas yang membuat kamu nyaman, penasaran, dan tahan mengerjakannya lebih lama.\",\n \"- Gunakan hasil ini sebagai titik awal diskusi jurusan, kegiatan, portofolio, atau pilihan karier.\",\n recommendations.length\n ? `- Rekomendasi awal yang bisa dipertimbangkan: ${recommendations.slice(0, 3).join(\", \")}.`\n : \"- Lengkapi juga tes lain agar gambaran belajar dan minat kamu lebih utuh.\",\n ].join(\"\\n\");\n}\n\nfunction buildUserResultsEmailHtml({ participant, attempt, rows, summary, testLabel, topRows }) {\n const topText = topRows.map((row) => `${row.label} (${row.score})`).join(\", \") || \"-\";\n const preheader = `Hasil ${testLabel} kamu sudah siap.`;\n\n return `\n \n\n
| \n
Belum ada skor.
`;\n\n const maxScore = maxScoreForTest(testId, rows);\n const body = rows.map((row) => {\n const percent = Math.max(0, Math.min(100, Math.round((Number(row.score) / maxScore) * 100)));\n return `\n${escapeHtml(line)}
`)\n .join(\"\");\n const bulletHtml = bullets.length\n ? `| Kategori | Skor |
|---|
Tes selesai: ${escapeHtml(attempt.testLabel || \"-\")}
\nWaktu submit: ${escapeHtml(formatDate(attempt.createdAt))}
\n| Nama | ${escapeHtml(participant.name || \"-\")} |
| Kelas | ${escapeHtml(participant.studentClass || \"-\")} |
| Umur | ${escapeHtml(participant.age || \"-\")} |
| No. HP | ${escapeHtml(participant.phone || \"-\")} |
| ${escapeHtml(participant.email || \"-\")} |
Belum selesai.
\"}\nBelum selesai.
\"}\n `;\n\n return { text, html };\n}\n\nasync function buildUserResultsEmail(body) {\n const participant = body.participant || {};\n const attempt = body.attempt || {};\n const labels = body.labels?.[attempt.testId] || {};\n const rows = scoreRows(attempt.scores, labels);\n const topRows = getTopScoreRows(rows);\n const summary = await buildAiEmailSummary(body, rows);\n const testLabel = attempt.testLabel || \"Tes Bekal10\";\n\n const text = [\n `Hasil ${testLabel} Bekal10`,\n \"\",\n `Halo ${participant.name || \"Peserta\"},`,\n `Tes selesai: ${testLabel}`,\n `Waktu submit: ${formatDate(attempt.createdAt)}`,\n \"\",\n `Kategori tertinggi: ${topRows.map((row) => `${row.label} (${row.score})`).join(\", \") || \"-\"}`,\n \"\",\n \"Skor:\",\n ...(rows.length ? rows.map((row) => `- ${row.label}: ${row.score}`) : [\"- Belum ada skor\"]),\n \"\",\n \"Ringkasan:\",\n summary,\n ].join(\"\\n\");\n\n const html = buildUserResultsEmailHtml({\n participant,\n attempt,\n rows,\n summary,\n testLabel,\n topRows,\n });\n\n return { text, html };\n}\n\nasync function buildAiEmailSummary(body, rows) {\n const fallback = buildFallbackEmailSummary(body, rows);\n if (!process.env.DEEPSEEK_API_KEY) return fallback;\n\n try {\n const apiResponse = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n model: DEEPSEEK_MODEL,\n messages: buildEmailSummaryMessages(body, rows),\n temperature: 0.2,\n max_tokens: 550,\n stream: false,\n user_id: sanitizeUserId(body.participant?.id || \"bekal10-user\"),\n }),\n });\n\n const data = await apiResponse.json().catch(() => ({}));\n if (!apiResponse.ok) return fallback;\n\n return cleanReply(getDeepSeekReply(data)) || fallback;\n } catch (error) {\n return fallback;\n }\n}\n\nfunction buildEmailSummaryMessages(body, rows) {\n const participant = body.participant || {};\n const attempt = body.attempt || {};\n const labels = body.labels || {};\n const scoreText = rows.map((row) => `${row.label}: ${row.score}`).join(\", \");\n const riasecScores = formatScores(body.riasec?.scores, labels.riasec);\n const varkScores = formatScores(body.vark?.scores, labels.vark);\n\n return [\n {\n role: \"system\",\n content: [\n \"Kamu menulis ringkasan hasil tes Bekal10 untuk email peserta.\",\n \"Jawab dalam Bahasa Indonesia.\",\n \"Gunakan plain text saja, tanpa markdown tebal dan tanpa tabel.\",\n \"Tulis 1 paragraf pembuka pendek dan 3 bullet yang diawali tanda '-'.\",\n \"Fokus pada tes yang baru selesai. Jika hasil tes lain tersedia, sebutkan hanya sebagai konteks pendukung.\",\n \"Jangan memberi diagnosis psikologis, jangan membuat klaim pasti, dan jangan terlalu panjang.\",\n ].join(\"\\n\"),\n },\n {\n role: \"user\",\n content: [\n `Nama: ${participant.name || \"Peserta\"}`,\n `Kelas: ${participant.studentClass || \"-\"}`,\n `Umur: ${participant.age || \"-\"}`,\n `Tes yang baru selesai: ${attempt.testLabel || attempt.testId || \"-\"}`,\n `Skor tes ini: ${scoreText || \"belum ada\"}`,\n `Skor RIASEC terbaru: ${riasecScores || \"belum ada\"}`,\n `Skor VARK terbaru: ${varkScores || \"belum ada\"}`,\n ].join(\"\\n\"),\n },\n ];\n}\n\nfunction buildFallbackEmailSummary(body, rows) {\n const attempt = body.attempt || {};\n const topRows = getTopScoreRows(rows);\n const topText = topRows.map((row) => `${row.label} (${row.score})`).join(\", \") || \"belum terlihat jelas\";\n\n if (attempt.testId === \"vark\") {\n return [\n `Hasil VARK kamu paling menonjol pada ${topText}. Ini bisa dipakai untuk memilih cara belajar yang terasa paling nyaman.`,\n \"- Gunakan gaya belajar terkuat sebagai strategi utama saat memahami materi baru.\",\n \"- Kombinasikan dengan gaya belajar lain agar proses belajar tidak terlalu sempit.\",\n \"- Jika ingin memilih jurusan atau karier, jadikan hasil RIASEC sebagai dasar utama dan VARK sebagai pendukung cara belajar.\",\n ].join(\"\\n\");\n }\n\n const recommendations = topRows.length ? recommendMajorsByRiasec(topRows[0].key) : [];\n return [\n `Hasil RIASEC kamu paling menonjol pada ${topText}. Ini menunjukkan area minat yang bisa mulai kamu eksplorasi lebih serius.`,\n \"- Perhatikan aktivitas yang membuat kamu nyaman, penasaran, dan tahan mengerjakannya lebih lama.\",\n \"- Gunakan hasil ini sebagai titik awal diskusi jurusan, kegiatan, portofolio, atau pilihan karier.\",\n recommendations.length\n ? `- Rekomendasi awal yang bisa dipertimbangkan: ${recommendations.slice(0, 3).join(\", \")}.`\n : \"- Lengkapi juga tes lain agar gambaran belajar dan minat kamu lebih utuh.\",\n ].join(\"\\n\");\n}\n\nfunction buildUserResultsEmailHtml({ participant, attempt, rows, summary, testLabel, topRows }) {\n const topText = topRows.map((row) => `${row.label} (${row.score})`).join(\", \") || \"-\";\n const preheader = `Hasil ${testLabel} kamu sudah siap.`;\n\n return `\n \n\n
| \n
Belum ada skor.
`;\n\n const maxScore = maxScoreForTest(testId, rows);\n const body = rows.map((row) => {\n const percent = Math.max(0, Math.min(100, Math.round((Number(row.score) / maxScore) * 100)));\n return `\n${escapeHtml(line)}
`)\n .join(\"\");\n const bulletHtml = bullets.length\n ? `| Kategori | Skor |
|---|
Tes selesai: ${escapeHtml(attempt.testLabel || \"-\")}
\nWaktu submit: ${escapeHtml(formatDate(attempt.createdAt))}
\n| Nama | ${escapeHtml(participant.name || \"-\")} |
| Kelas | ${escapeHtml(participant.studentClass || \"-\")} |
| Umur | ${escapeHtml(participant.age || \"-\")} |
| No. HP | ${escapeHtml(participant.phone || \"-\")} |
Belum selesai.
\"}\nBelum selesai.
\"}\n `;\n\n return { text, html };\n}\n\nfunction saveResultJson(body) {\n const resultDir = path.resolve(process.cwd(), process.env.RESULT_DIR || \"data/results\");\n fs.mkdirSync(resultDir, { recursive: true });\n\n const participantName = safeFilePart(body.participant?.name || \"peserta\");\n const testId = safeFilePart(body.attempt?.testId || \"test\");\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const fileName = `${timestamp}_${participantName}_${testId}.json`;\n const filePath = path.join(resultDir, fileName);\n const payload = {\n savedAt: new Date().toISOString(),\n participant: body.participant || null,\n submittedTest: body.attempt || null,\n latestResults: {\n riasec: body.riasec || null,\n vark: body.vark || null,\n },\n };\n\n fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), \"utf8\");\n upsertHasilJson(resultDir, payload);\n return path.relative(process.cwd(), filePath).replace(/\\\\/g, \"/\");\n}\n\nfunction upsertHasilJson(resultDir, payload) {\n const hasilPath = path.join(resultDir, \"hasil.json\");\n let collection = [];\n\n if (fs.existsSync(hasilPath)) {\n try {\n const existing = JSON.parse(fs.readFileSync(hasilPath, \"utf8\"));\n collection = Array.isArray(existing) ? existing : [];\n } catch {\n collection = [];\n }\n }\n\n const participantId = payload.participant?.id;\n const existingIndex = collection.findIndex((item) => item.participant?.id === participantId);\n\n if (existingIndex >= 0) {\n collection[existingIndex] = payload;\n } else {\n collection.push(payload);\n }\n\n fs.writeFileSync(hasilPath, JSON.stringify(collection, null, 2), \"utf8\");\n}\n\nfunction safeFilePart(value) {\n return String(value)\n .trim()\n .replace(/[^a-zA-Z0-9_-]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n .slice(0, 60) || \"data\";\n}\n\nfunction scoreRows(scores = {}, labels = {}) {\n return Object.entries(scores)\n .map(([key, score]) => ({\n label: labels[key] || key,\n score,\n }))\n .sort((a, b) => b.score - a.score);\n}\n\nfunction formatDate(value) {\n if (!value) return \"-\";\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) return value;\n return date.toLocaleString(\"id-ID\", { timeZone: \"Asia/Jakarta\" });\n}\n\nfunction scoreTable(rows) {\n const body = rows\n .map((row) => `| Kategori | Skor |
|---|