Files
monitoring-app/src/app.ts
amaliadwiy d09a702d64 upd: swagger docs, api key auth, bug fixes
- tambah Elysia Swagger di /docs dengan deskripsi lengkap semua endpoint
- tambah API key auth (X-API-Key) untuk klien eksternal di POST /api/bugs
- tambah normalisasi BugSource: SYSTEM/USER untuk eksternal, QC/SYSTEM/USER untuk dashboard
- perbaiki source schema jadi optional string agar tidak reject nilai unknown dari klien lama
- hapus field status dari form create bug (selalu OPEN)
- perbaiki typo desa_plus → appId di apps.$appId.errors.tsx
- tambah toggle hide/show stack trace di bug-reports.tsx dan apps.$appId.errors.tsx
- perbaiki grafik desa (width(-1)/height(-1)) dengan minWidth: 0 pada grid item
- perbaiki error &[data-active] inline style di DashboardLayout → pindah ke CSS class
- update CLAUDE.md dengan arsitektur lengkap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:30:07 +08:00

811 lines
30 KiB
TypeScript

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<AuthResult | null> {
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'] },
})
}