import { cors } from '@elysiajs/cors' import { html } from '@elysiajs/html' import { swagger } from '@elysiajs/swagger' import { Elysia, t } from 'elysia' import { BugSource } from '../generated/prisma' import { prisma } from './lib/db' import { env } from './lib/env' import { createSystemLog } from './lib/logger' interface AuthResult { actingUserId: string reporterUserId: string | null // null jika via API key (tidak ada user spesifik) isApiKey: boolean // true = dari klien eksternal (mobile app) } // Validates session cookie OR X-API-Key header. Returns null if neither is valid. async function checkAuth(request: Request): Promise { const cookie = request.headers.get('cookie') ?? '' const token = cookie.match(/session=([^;]+)/)?.[1] if (token) { const session = await prisma.session.findUnique({ where: { token } }) if (session && session.expiresAt > new Date()) { return { actingUserId: session.userId, reporterUserId: session.userId, isApiKey: false } } } const apiKey = request.headers.get('x-api-key') if (apiKey && apiKey === env.API_KEY) { const developer = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }) if (!developer) return null return { actingUserId: developer.id, reporterUserId: null, isApiKey: true } } return null } export function createApp() { return new Elysia() .use(swagger({ path: '/docs', documentation: { info: { title: 'Monitoring App API', version: '0.1.0' }, tags: [ { name: 'Auth', description: 'Autentikasi dan manajemen sesi' }, { name: 'Dashboard', description: 'Statistik dan ringkasan monitoring' }, { name: 'Apps', description: 'Manajemen aplikasi yang dimonitor' }, { name: 'Bugs', description: 'Manajemen laporan bug' }, { name: 'Logs', description: 'Log aktivitas sistem' }, { name: 'Operators', description: 'Manajemen operator / pengguna sistem' }, { name: 'System', description: 'Status dan kesehatan sistem' }, ], }, })) .use(cors()) .use(html()) // ─── Global Error Handler ──────────────────────── .onError(({ code, error }) => { if (code === 'NOT_FOUND') { return new Response(JSON.stringify({ error: 'Not Found', status: 404 }), { status: 404, headers: { 'Content-Type': 'application/json' }, }) } console.error('[Server Error]', error) return new Response(JSON.stringify({ error: 'Internal Server Error', status: 500 }), { status: 500, headers: { 'Content-Type': 'application/json' }, }) }) // ─── Health ─────────────────────────────────────── .get('/health', () => ({ status: 'ok' }), { detail: { summary: 'Health Check', description: 'Memeriksa apakah server sedang berjalan.', tags: ['System'], }, }) // ─── Auth API ────────────────────────────────────── .post('/api/auth/login', async ({ body, set }) => { const { email, password } = body let user = await prisma.user.findUnique({ where: { email } }) if (!user || !(await Bun.password.verify(password, user.password))) { set.status = 401 return { error: 'Email atau password salah' } } // Auto-promote super admin from env if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') { user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } }) } const token = crypto.randomUUID() const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` await createSystemLog(user.id, 'LOGIN', 'Logged in successfully') return { user: { id: user.id, name: user.name, email: user.email, role: user.role } } }, { body: t.Object({ email: t.String({ format: 'email', description: 'Email pengguna' }), password: t.String({ minLength: 1, description: 'Password pengguna' }), }), detail: { summary: 'Login', description: 'Login dengan email dan password. Mengembalikan data user dan set session cookie (HttpOnly, 24 jam). Jika email terdaftar di SUPER_ADMIN_EMAIL, role otomatis di-promote ke DEVELOPER.', tags: ['Auth'], }, }) .post('/api/auth/logout', async ({ request, set }) => { const cookie = request.headers.get('cookie') ?? '' const token = cookie.match(/session=([^;]+)/)?.[1] if (token) { const sessionObj = await prisma.session.findUnique({ where: { token } }) if (sessionObj) { await createSystemLog(sessionObj.userId, 'LOGOUT', 'Logged out successfully') await prisma.session.deleteMany({ where: { token } }) } } set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0' return { ok: true } }, { detail: { summary: 'Logout', description: 'Menghapus sesi aktif dari database dan membersihkan session cookie.', tags: ['Auth'], }, }) .get('/api/auth/session', async ({ request, set }) => { const cookie = request.headers.get('cookie') ?? '' const token = cookie.match(/session=([^;]+)/)?.[1] if (!token) { set.status = 401; return { user: null } } const session = await prisma.session.findUnique({ where: { token }, include: { user: { select: { id: true, name: true, email: true, role: true } } }, }) if (!session || session.expiresAt < new Date()) { if (session) await prisma.session.delete({ where: { id: session.id } }) set.status = 401 return { user: null } } return { user: session.user } }, { detail: { summary: 'Get Current Session', description: 'Mengembalikan data user dari sesi aktif berdasarkan session cookie. Mengembalikan 401 jika tidak ada sesi atau sudah kadaluarsa.', tags: ['Auth'], }, }) // ─── Dashboard API ───────────────────────────────── .get('/api/dashboard/stats', async () => { const newErrors = await prisma.bug.count({ where: { status: 'OPEN' } }) const users = await prisma.user.count() return { totalApps: 1, newErrors: newErrors, activeUsers: users, trends: { totalApps: 0, newErrors: 12, activeUsers: 5.2 } } }, { detail: { summary: 'Dashboard Stats', description: 'Mengembalikan statistik utama dashboard: total aplikasi, jumlah error baru (status OPEN), total pengguna, dan data tren.', tags: ['Dashboard'], }, }) .get('/api/dashboard/recent-errors', async () => { const bugs = await prisma.bug.findMany({ take: 5, orderBy: { createdAt: 'desc' } }) return bugs.map(b => ({ id: b.id, app: b.appId, message: b.description, version: b.affectedVersion, time: b.createdAt.toISOString(), severity: b.status })) }, { detail: { summary: 'Recent Errors', description: 'Mengembalikan 5 bug report terbaru (diurutkan dari yang terbaru) untuk ditampilkan di dashboard.', tags: ['Dashboard'], }, }) // ─── Apps API ────────────────────────────────────── .get('/api/apps', async ({ query }) => { const search = query.search || '' const where: any = {} if (search) { where.name = { contains: search, mode: 'insensitive' } } const apps = await prisma.app.findMany({ where, include: { _count: { select: { bugs: true } }, bugs: { where: { status: 'OPEN' }, select: { id: true } }, }, orderBy: { name: 'asc' }, }) return apps.map((app) => ({ id: app.id, name: app.name, status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active', errors: app.bugs.length, version: app.version ?? '-', maintenance: app.maintenance, })) }, { query: t.Object({ search: t.Optional(t.String({ description: 'Filter berdasarkan nama aplikasi' })), }), detail: { summary: 'List Apps', description: 'Mengembalikan semua aplikasi yang dimonitor beserta status (active/warning/error), jumlah bug OPEN, versi, dan mode maintenance.', tags: ['Apps'], }, }) .get('/api/apps/:appId', async ({ params: { appId }, set }) => { const app = await prisma.app.findUnique({ where: { id: appId }, include: { _count: { select: { bugs: true } }, bugs: { where: { status: 'OPEN' }, select: { id: true } }, }, }) if (!app) { set.status = 404 return { error: 'App not found' } } return { id: app.id, name: app.name, status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active', errors: app.bugs.length, version: app.version ?? '-', minVersion: app.minVersion, maintenance: app.maintenance, totalBugs: app._count.bugs, } }, { params: t.Object({ appId: t.String({ description: 'ID aplikasi (contoh: desa-plus)' }), }), detail: { summary: 'Get App Detail', description: 'Mengembalikan detail satu aplikasi berdasarkan ID, termasuk status, versi minimum, mode maintenance, dan total semua bug.', tags: ['Apps'], }, }) // ─── Logs API ────────────────────────────────────── .get('/api/logs', async ({ query }) => { const page = Number(query.page) || 1 const limit = Number(query.limit) || 20 const search = query.search || '' const type = query.type as any const userId = query.userId const where: any = {} if (search) { where.OR = [ { message: { contains: search, mode: 'insensitive' } }, { user: { name: { contains: search, mode: 'insensitive' } } } ] } if (type && type !== 'all') { where.type = type } if (userId && userId !== 'all') { where.userId = userId } const [logs, total] = await Promise.all([ prisma.log.findMany({ where, include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), prisma.log.count({ where }) ]) return { data: logs, totalPages: Math.ceil(total / limit), totalItems: total } }, { query: t.Object({ page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })), limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })), search: t.Optional(t.String({ description: 'Cari berdasarkan pesan log atau nama pengguna' })), type: t.Optional(t.String({ description: 'Filter tipe: CREATE | UPDATE | DELETE | LOGIN | LOGOUT | all' })), userId: t.Optional(t.String({ description: 'Filter berdasarkan ID pengguna, atau "all"' })), }), detail: { summary: 'List Activity Logs', description: 'Mengembalikan log aktivitas sistem dengan pagination. Mendukung filter berdasarkan tipe log (CREATE, UPDATE, DELETE, LOGIN, LOGOUT) dan pengguna.', tags: ['Logs'], }, }) .post('/api/logs', async ({ body, request }) => { const cookie = request.headers.get('cookie') ?? '' const token = cookie.match(/session=([^;]+)/)?.[1] let userId: string | undefined if (token) { const session = await prisma.session.findUnique({ where: { token } }) if (session && session.expiresAt > new Date()) userId = session.userId } const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }))?.id || '' await createSystemLog(actingUserId, body.type as any, body.message) return { ok: true } }, { body: t.Object({ type: t.String({ description: 'Tipe log: CREATE | UPDATE | DELETE | LOGIN | LOGOUT' }), message: t.String({ description: 'Pesan log yang akan dicatat' }), }), detail: { summary: 'Create Log', description: 'Mencatat log aktivitas sistem. Jika ada session cookie yang valid, log dikaitkan ke pengguna yang sedang login. Jika tidak, log dikaitkan ke akun DEVELOPER pertama.', tags: ['Logs'], }, }) .get('/api/logs/operators', async () => { return await prisma.user.findMany({ select: { id: true, name: true, image: true }, orderBy: { name: 'asc' } }) }, { detail: { summary: 'List Operators for Log Filter', description: 'Mengembalikan daftar semua pengguna (id, name, image) sebagai opsi filter pada halaman log aktivitas.', tags: ['Logs'], }, }) // ─── Operators API ───────────────────────────────── .get('/api/operators', async ({ query }) => { const page = Number(query.page) || 1 const limit = Number(query.limit) || 20 const search = query.search || '' const where: any = {} if (search) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } } ] } const [users, total] = await Promise.all([ prisma.user.findMany({ where, select: { id: true, name: true, email: true, role: true, active: true, image: true, createdAt: true }, orderBy: { name: 'asc' }, skip: (page - 1) * limit, take: limit, }), prisma.user.count({ where }) ]) return { data: users, totalPages: Math.ceil(total / limit), totalItems: total } }, { query: t.Object({ page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })), limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })), search: t.Optional(t.String({ description: 'Cari berdasarkan nama atau email' })), }), detail: { summary: 'List Operators', description: 'Mengembalikan daftar operator/pengguna sistem dengan pagination. Mendukung pencarian berdasarkan nama dan email.', tags: ['Operators'], }, }) .get('/api/operators/stats', async () => { const [totalStaff, activeNow, rolesGroup] = await Promise.all([ prisma.user.count({ where: { active: true } }), prisma.session.count({ where: { expiresAt: { gte: new Date() } }, }), prisma.user.groupBy({ by: ['role'], _count: true }) ]) return { totalStaff, activeNow, rolesCount: rolesGroup.length } }, { detail: { summary: 'Operator Stats', description: 'Mengembalikan statistik operator: total staf aktif, jumlah sesi yang sedang aktif saat ini, dan jumlah role yang ada.', tags: ['Operators'], }, }) .post('/api/operators', async ({ body, request, set }) => { const cookie = request.headers.get('cookie') ?? '' const token = cookie.match(/session=([^;]+)/)?.[1] let userId: string | undefined if (token) { const session = await prisma.session.findUnique({ where: { token } }) if (session && session.expiresAt > new Date()) userId = session.userId } const existing = await prisma.user.findUnique({ where: { email: body.email } }) if (existing) { set.status = 400 return { error: 'Email sudah terdaftar' } } const hashedPassword = await Bun.password.hash(body.password) const user = await prisma.user.create({ data: { name: body.name, email: body.email, password: hashedPassword, role: body.role as any, }, }) if (userId) { await createSystemLog(userId, 'CREATE', `Created new user: ${body.name} (${body.email})`) } return { id: user.id, name: user.name, email: user.email, role: user.role } }, { body: t.Object({ name: t.String({ minLength: 1, description: 'Nama lengkap operator' }), email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }), password: t.String({ minLength: 6, description: 'Password (minimal 6 karakter)' }), role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role: ADMIN atau DEVELOPER' }), }), detail: { summary: 'Create Operator', description: 'Membuat akun operator baru. Password di-hash dengan bcrypt sebelum disimpan. Gagal jika email sudah terdaftar.', tags: ['Operators'], }, }) .patch('/api/operators/:id', async ({ params: { id }, body, request }) => { const cookie = request.headers.get('cookie') ?? '' const token = cookie.match(/session=([^;]+)/)?.[1] let userId: string | undefined if (token) { const session = await prisma.session.findUnique({ where: { token } }) if (session && session.expiresAt > new Date()) userId = session.userId } const user = await prisma.user.update({ where: { id }, data: { ...(body.name !== undefined && { name: body.name }), ...(body.email !== undefined && { email: body.email }), ...(body.role !== undefined && { role: body.role as any }), ...(body.active !== undefined && { active: body.active }), }, }) if (userId) { await createSystemLog(userId, 'UPDATE', `Updated user: ${user.name} (${user.email})`) } return { id: user.id, name: user.name, email: user.email, role: user.role, active: user.active } }, { params: t.Object({ id: t.String({ description: 'ID operator yang akan diupdate' }), }), body: t.Object({ name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })), email: t.Optional(t.String({ format: 'email', description: 'Email baru' })), role: t.Optional(t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })), active: t.Optional(t.Boolean({ description: 'Status aktif operator' })), }), detail: { summary: 'Update Operator', description: 'Mengupdate data operator secara parsial. Semua field bersifat opsional — hanya field yang dikirim yang akan diupdate.', tags: ['Operators'], }, }) .delete('/api/operators/:id', async ({ params: { id }, request, set }) => { const cookie = request.headers.get('cookie') ?? '' const token = cookie.match(/session=([^;]+)/)?.[1] let userId: string | undefined if (token) { const session = await prisma.session.findUnique({ where: { token } }) if (session && session.expiresAt > new Date()) userId = session.userId } const user = await prisma.user.findUnique({ where: { id } }) if (!user) { set.status = 404 return { error: 'User not found' } } // Prevent deleting self if (userId === id) { set.status = 400 return { error: 'Cannot delete your own account' } } await prisma.session.deleteMany({ where: { userId: id } }) await prisma.user.update({ where: { id }, data: { active: false } }) if (userId) { await createSystemLog(userId, 'DELETE', `Deactivated user: ${user.name} (${user.email})`) } return { ok: true } }, { params: t.Object({ id: t.String({ description: 'ID operator yang akan dideactivate' }), }), detail: { summary: 'Deactivate Operator', description: 'Menonaktifkan akun operator (soft delete: set active=false). Semua sesi aktif operator tersebut ikut dihapus. Tidak bisa menghapus akun sendiri.', tags: ['Operators'], }, }) // ─── Bugs API ────────────────────────────────────── .get('/api/bugs', async ({ query }) => { const page = Number(query.page) || 1 const limit = Number(query.limit) || 20 const search = query.search || '' const app = query.app as any const status = query.status as any const where: any = {} if (search) { where.OR = [ { description: { contains: search, mode: 'insensitive' } }, { device: { contains: search, mode: 'insensitive' } }, { os: { contains: search, mode: 'insensitive' } }, { affectedVersion: { contains: search, mode: 'insensitive' } }, ] } if (app && app !== 'all') { where.appId = app } if (status && status !== 'all') { where.status = status } const [bugs, total] = await Promise.all([ prisma.bug.findMany({ where, include: { user: { select: { id: true, name: true, email: true, image: true } }, images: true, logs: { include: { user: { select: { id: true, name: true, image: true } } }, orderBy: { createdAt: 'desc' }, }, }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), prisma.bug.count({ where }), ]) return { data: bugs, totalPages: Math.ceil(total / limit), totalItems: total, } }, { query: t.Object({ page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })), limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })), search: t.Optional(t.String({ description: 'Cari berdasarkan deskripsi, device, OS, atau versi' })), app: t.Optional(t.String({ description: 'Filter berdasarkan ID aplikasi, atau "all"' })), status: t.Optional(t.String({ description: 'Filter status: OPEN | ON_HOLD | IN_PROGRESS | RESOLVED | RELEASED | CLOSED | all' })), }), detail: { summary: 'List Bug Reports', description: 'Mengembalikan daftar bug report dengan pagination, beserta data pelapor, gambar, dan riwayat status (BugLog). Mendukung filter berdasarkan aplikasi dan status.', tags: ['Bugs'], }, }) .post('/api/bugs', async ({ body, request, set }) => { const auth = await checkAuth(request) if (!auth) { set.status = 401 return { error: 'Unauthorized: sertakan session cookie atau header X-API-Key' } } const { actingUserId, reporterUserId, isApiKey } = auth const bug = await prisma.bug.create({ data: { appId: body.app, affectedVersion: body.affectedVersion, device: body.device, os: body.os, status: 'OPEN', source: body.source as BugSource, description: body.description, stackTrace: body.stackTrace, userId: reporterUserId, images: body.imageUrl ? { create: { imageUrl: body.imageUrl } } : undefined, logs: { create: { userId: actingUserId, status: 'OPEN', description: 'Bug reported initially.', }, }, }, }) return bug }, { body: t.Object({ app: t.Optional(t.String({ description: 'ID aplikasi terkait (contoh: desa-plus)' })), affectedVersion: t.String({ description: 'Versi aplikasi yang terdampak bug' }), device: t.String({ description: 'Tipe/model perangkat pengguna' }), os: t.String({ description: 'Sistem operasi perangkat (contoh: Android 13, iOS 17)' }), description: t.String({ minLength: 1, description: 'Deskripsi bug yang ditemukan' }), stackTrace: t.Optional(t.String({ description: 'Stack trace error (opsional)' })), source: t.Optional(t.String({ description: 'Sumber laporan: QC | SYSTEM | USER', })), imageUrl: t.Optional(t.String({ description: 'URL gambar screenshot bug (opsional)' })), }), detail: { summary: 'Create Bug Report', description: 'Membuat laporan bug baru dengan status awal OPEN. Bisa diakses via session cookie (frontend) atau X-API-Key (klien eksternal seperti Desa+). Jika via API key, userId pelapor null dan source default USER.', tags: ['Bugs'], }, }) .patch('/api/bugs/:id/feedback', async ({ params: { id }, body, request }) => { const cookie = request.headers.get('cookie') ?? '' const token = cookie.match(/session=([^;]+)/)?.[1] let userId: string | undefined if (token) { const session = await prisma.session.findUnique({ where: { token } }) if (session && session.expiresAt > new Date()) { userId = session.userId } } const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }) const actingUserId = userId || defaultAdmin?.id || undefined const bug = await prisma.bug.update({ where: { id }, data: { feedBack: body.feedBack }, }) if (actingUserId) { await createSystemLog(actingUserId, 'UPDATE', `Updated bug report feedback - ${id}`) } return bug }, { params: t.Object({ id: t.String({ description: 'ID bug report' }), }), body: t.Object({ feedBack: t.String({ description: 'Feedback atau catatan developer untuk bug ini' }), }), detail: { summary: 'Update Bug Feedback', description: 'Menambahkan atau mengupdate feedback/catatan developer pada sebuah bug report.', tags: ['Bugs'], }, }) .patch('/api/bugs/:id/status', async ({ params: { id }, body, request }) => { const cookie = request.headers.get('cookie') ?? '' const token = cookie.match(/session=([^;]+)/)?.[1] let userId: string | undefined if (token) { const session = await prisma.session.findUnique({ where: { token } }) if (session && session.expiresAt > new Date()) { userId = session.userId } } const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }) const actingUserId = userId || defaultAdmin?.id || undefined const bug = await prisma.bug.update({ where: { id }, data: { status: body.status as any, logs: { create: { userId: actingUserId, status: body.status as any, description: body.description || `Status updated to ${body.status}`, }, }, }, }) if (actingUserId) { await createSystemLog(actingUserId, 'UPDATE', `Updated bug report status to ${body.status}-${id}`) } return bug }, { params: t.Object({ id: t.String({ description: 'ID bug report' }), }), body: t.Object({ status: t.Union( [ t.Literal('OPEN'), t.Literal('ON_HOLD'), t.Literal('IN_PROGRESS'), t.Literal('RESOLVED'), t.Literal('RELEASED'), t.Literal('CLOSED'), ], { description: 'Status baru bug' } ), description: t.Optional(t.String({ description: 'Catatan perubahan status (opsional)' })), }), detail: { summary: 'Update Bug Status', description: 'Mengubah status bug dan otomatis membuat entri BugLog baru sebagai riwayat perubahan status.', tags: ['Bugs'], }, }) // ─── System Status API ───────────────────────────── .get('/api/system/status', async () => { try { // Check database connectivity await prisma.$queryRaw`SELECT 1` const activeSessions = await prisma.session.count({ where: { expiresAt: { gte: new Date() } }, }) return { status: 'operational', database: 'connected', activeSessions, uptime: process.uptime(), } } catch { return { status: 'degraded', database: 'disconnected', activeSessions: 0, uptime: process.uptime(), } } }, { detail: { summary: 'System Status', description: 'Memeriksa status operasional sistem: koneksi database dan jumlah sesi aktif. Mengembalikan status "degraded" jika database tidak dapat dijangkau.', tags: ['System'], }, }) // ─── Example API ─────────────────────────────────── .get('/api/hello', () => ({ message: 'Hello, world!', method: 'GET', }), { detail: { summary: 'Hello GET', tags: ['System'] }, }) .put('/api/hello', () => ({ message: 'Hello, world!', method: 'PUT', }), { detail: { summary: 'Hello PUT', tags: ['System'] }, }) .get('/api/hello/:name', ({ params }) => ({ message: `Hello, ${params.name}!`, }), { params: t.Object({ name: t.String({ description: 'Nama yang akan disapa' }), }), detail: { summary: 'Hello by Name', tags: ['System'] }, }) }