- Tambah GET /api/bugs/stats dengan summary cards & chart trend/bugs per app - Tambah date range picker di village activity chart - Tambah tabel Recent Activity (action + description) di village detail - Update API graph-log-villages support dateFrom/dateTo custom range
1863 lines
88 KiB
TypeScript
1863 lines
88 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 { appLog, clearAppLogs, getAppLogs } from './lib/applog'
|
|
import { prisma } from './lib/db'
|
|
import { env } from './lib/env'
|
|
import { createSystemLog } from './lib/logger'
|
|
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio'
|
|
import { addConnection, broadcastNotification, broadcastToAdmins, getOnlineUserIds, removeConnection } from './lib/presence'
|
|
import { parseSchema } from './lib/schema-parser'
|
|
|
|
const isProduction = process.env.NODE_ENV === 'production'
|
|
const cookieFlags = isProduction ? '; Secure' : ''
|
|
|
|
function getPublicOrigin(request: Request): string {
|
|
if (process.env.BUN_PUBLIC_BASE_URL) return process.env.BUN_PUBLIC_BASE_URL.replace(/\/$/, '')
|
|
const url = new URL(request.url)
|
|
const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim()
|
|
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
|
return `${proto ?? url.protocol.replace(':', '')}://${host}`
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function requireDeveloper(request: Request, set: { status?: number | string }): Promise<{ userId: string; role: string } | null> {
|
|
const cookie = request.headers.get('cookie') ?? ''
|
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
|
if (!token) { set.status = 401; return null }
|
|
const session = await prisma.session.findUnique({
|
|
where: { token },
|
|
include: { user: { select: { id: true, role: true } } },
|
|
})
|
|
if (!session || session.expiresAt < new Date()) { set.status = 401; return null }
|
|
if (session.user.role !== 'DEVELOPER') { set.status = 403; return null }
|
|
return { userId: session.user.id, role: session.user.role }
|
|
}
|
|
|
|
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())
|
|
|
|
// ─── Request timing + app log broadcasting ────────
|
|
.onRequest(({ request }) => {
|
|
;(request as any).__startTime = performance.now()
|
|
})
|
|
.onAfterResponse(({ request, set }) => {
|
|
const url = new URL(request.url)
|
|
if (url.pathname.startsWith('/api/')) {
|
|
const status = typeof set.status === 'number' ? set.status : 200
|
|
const level = status >= 500 ? ('error' as const) : status >= 400 ? ('warn' as const) : ('info' as const)
|
|
appLog(level, `${request.method} ${url.pathname} ${status}`)
|
|
const duration = Math.round(performance.now() - ((request as any).__startTime || 0))
|
|
broadcastToAdmins({ type: 'request', method: request.method, path: url.pathname, status, duration, timestamp: new Date().toISOString() })
|
|
}
|
|
})
|
|
|
|
// ─── 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 ──────────────────────────────────────
|
|
// ─── Google OAuth ──────────────────────────────────
|
|
.get('/api/auth/google', ({ request }) => {
|
|
const origin = getPublicOrigin(request)
|
|
const state = crypto.randomUUID()
|
|
const params = new URLSearchParams({
|
|
client_id: env.GOOGLE_CLIENT_ID,
|
|
redirect_uri: `${origin}/api/auth/callback/google`,
|
|
response_type: 'code',
|
|
scope: 'openid email profile',
|
|
state,
|
|
access_type: 'online',
|
|
prompt: 'select_account',
|
|
})
|
|
const headers = new Headers()
|
|
headers.set('Location', `https://accounts.google.com/o/oauth2/v2/auth?${params}`)
|
|
headers.set('Set-Cookie', `oauth_state=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600${cookieFlags}`)
|
|
return new Response(null, { status: 302, headers })
|
|
}, {
|
|
detail: {
|
|
summary: 'Google OAuth Login',
|
|
description: 'Menginisiasi alur Google OAuth. Meredirect pengguna ke halaman login Google.',
|
|
tags: ['Auth'],
|
|
},
|
|
})
|
|
|
|
.get('/api/auth/callback/google', async ({ query, request }) => {
|
|
const { code, state, error } = query as Record<string, string>
|
|
const origin = getPublicOrigin(request)
|
|
|
|
if (error) {
|
|
return new Response(null, { status: 302, headers: { Location: '/login?error=google_denied' } })
|
|
}
|
|
|
|
const cookie = request.headers.get('cookie') ?? ''
|
|
const storedState = cookie.match(/oauth_state=([^;]+)/)?.[1]
|
|
if (!state || !storedState || state !== storedState) {
|
|
return new Response(null, { status: 302, headers: { Location: '/login?error=invalid_state' } })
|
|
}
|
|
|
|
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
code,
|
|
client_id: env.GOOGLE_CLIENT_ID,
|
|
client_secret: env.GOOGLE_CLIENT_SECRET,
|
|
redirect_uri: `${origin}/api/auth/callback/google`,
|
|
grant_type: 'authorization_code',
|
|
}),
|
|
})
|
|
|
|
if (!tokenRes.ok) {
|
|
return new Response(null, { status: 302, headers: { Location: '/login?error=token_failed' } })
|
|
}
|
|
|
|
const { access_token } = await tokenRes.json() as { access_token: string }
|
|
|
|
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
headers: { Authorization: `Bearer ${access_token}` },
|
|
})
|
|
|
|
if (!userInfoRes.ok) {
|
|
return new Response(null, { status: 302, headers: { Location: '/login?error=userinfo_failed' } })
|
|
}
|
|
|
|
const { id: googleId, email, name, picture } = await userInfoRes.json() as {
|
|
id: string; email: string; name: string; picture?: string
|
|
}
|
|
|
|
let user = await prisma.user.findFirst({
|
|
where: { OR: [{ googleId }, { email }] },
|
|
})
|
|
|
|
if (!user) {
|
|
user = await prisma.user.create({
|
|
data: { name, email, googleId, image: picture, role: 'USER' },
|
|
})
|
|
} else if (!user.googleId) {
|
|
user = await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { googleId, image: picture ?? user.image },
|
|
})
|
|
}
|
|
|
|
if (!user.active) {
|
|
return new Response(null, { status: 302, headers: { Location: '/login?error=account_disabled' } })
|
|
}
|
|
|
|
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)
|
|
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
|
await createSystemLog(user.id, 'LOGIN', 'Logged in with Google')
|
|
|
|
const redirectPath = user.role === 'DEVELOPER' ? '/dev' : user.role === 'USER' ? '/profile' : '/dashboard'
|
|
const headers = new Headers()
|
|
headers.append('Location', redirectPath)
|
|
headers.append('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${cookieFlags}`)
|
|
headers.append('Set-Cookie', `oauth_state=; Path=/; HttpOnly; Max-Age=0${cookieFlags}`)
|
|
return new Response(null, { status: 302, headers })
|
|
}, {
|
|
detail: {
|
|
summary: 'Google OAuth Callback',
|
|
description: 'Menerima callback dari Google, membuat/menautkan akun, membuat sesi, lalu meredirect ke /dashboard (ADMIN/DEVELOPER) atau /profile (USER baru).',
|
|
tags: ['Auth'],
|
|
},
|
|
})
|
|
|
|
.post('/api/auth/login', async ({ body, set }) => {
|
|
const { email, password } = body
|
|
let user = await prisma.user.findUnique({ where: { email } })
|
|
if (!user || !user.password || !(await Bun.password.verify(password, user.password))) {
|
|
set.status = 401
|
|
return { error: 'Email atau password salah' }
|
|
}
|
|
if (!user.active) {
|
|
set.status = 403
|
|
return { error: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.' }
|
|
}
|
|
// 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${cookieFlags}`
|
|
await createSystemLog(user.id, 'LOGIN', 'Logged in successfully')
|
|
return { user: { id: user.id, name: user.name, email: user.email, role: user.role, image: user.image } }
|
|
}, {
|
|
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${cookieFlags}`
|
|
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, image: true, active: true } } },
|
|
})
|
|
if (!session || session.expiresAt < new Date()) {
|
|
if (session) await prisma.session.delete({ where: { id: session.id } })
|
|
set.status = 401
|
|
return { user: null }
|
|
}
|
|
if (!session.user.active) {
|
|
await prisma.session.deleteMany({ where: { userId: session.user.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 all = query.all === 'true'
|
|
const where: any = all ? {} : { active: true }
|
|
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.active ? 'active' : 'inactive',
|
|
errors: app.bugs.length,
|
|
active: app.active,
|
|
urlApi: app.urlApi,
|
|
apiKey: app.apiKey ?? '',
|
|
clientApiKey: app.clientApiKey ?? '',
|
|
hasClientApiKey: !!app.clientApiKey,
|
|
}))
|
|
}, {
|
|
query: t.Object({
|
|
search: t.Optional(t.String()),
|
|
all: t.Optional(t.String()),
|
|
}),
|
|
detail: {
|
|
summary: 'List Apps',
|
|
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.active ? 'active' : 'inactive',
|
|
errors: app.bugs.length,
|
|
urlApi: app.urlApi,
|
|
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'],
|
|
},
|
|
})
|
|
|
|
.post('/api/apps', async ({ body, request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const { id, name, version, minVersion, maintenance, urlApi, apiKey } = body as any
|
|
if (!id || !name) { set.status = 400; return { error: 'id and name are required' } }
|
|
const existing = await prisma.app.findUnique({ where: { id } })
|
|
if (existing) { set.status = 409; return { error: 'App with this ID already exists' } }
|
|
const app = await prisma.app.create({
|
|
data: { id, name, version: version || null, minVersion: minVersion || null, maintenance: maintenance ?? false, urlApi: urlApi || null, apiKey: apiKey || null },
|
|
})
|
|
await createSystemLog(auth.userId, 'CREATE', `Created app: ${app.id}`)
|
|
return { id: app.id, name: app.name, version: app.version, minVersion: app.minVersion, maintenance: app.maintenance, urlApi: app.urlApi }
|
|
}, {
|
|
detail: { summary: 'Create App', tags: ['Apps'] },
|
|
})
|
|
|
|
.patch('/api/apps/:appId', async ({ params: { appId }, body, request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const app = await prisma.app.findUnique({ where: { id: appId } })
|
|
if (!app) { set.status = 404; return { error: 'App not found' } }
|
|
const { name, version, minVersion, maintenance, urlApi, apiKey } = body as any
|
|
const updated = await prisma.app.update({
|
|
where: { id: appId },
|
|
data: {
|
|
...(name !== undefined && { name }),
|
|
...(version !== undefined && { version: version || null }),
|
|
...(minVersion !== undefined && { minVersion: minVersion || null }),
|
|
...(maintenance !== undefined && { maintenance }),
|
|
...(urlApi !== undefined && { urlApi: urlApi || null }),
|
|
...(apiKey !== undefined && { apiKey: apiKey || null }),
|
|
},
|
|
})
|
|
await createSystemLog(auth.userId, 'UPDATE', `Updated app: ${appId}`)
|
|
return { id: updated.id, name: updated.name, version: updated.version, minVersion: updated.minVersion, maintenance: updated.maintenance, urlApi: updated.urlApi }
|
|
}, {
|
|
params: t.Object({ appId: t.String() }),
|
|
detail: { summary: 'Update App', tags: ['Apps'] },
|
|
})
|
|
|
|
.delete('/api/apps/:appId', async ({ params: { appId }, request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const app = await prisma.app.findUnique({ where: { id: appId } })
|
|
if (!app) { set.status = 404; return { error: 'App not found' } }
|
|
await prisma.app.update({ where: { id: appId }, data: { active: false } })
|
|
await createSystemLog(auth.userId, 'UPDATE', `Deactivated app: ${appId}`)
|
|
return { success: true }
|
|
}, {
|
|
params: t.Object({ appId: t.String() }),
|
|
detail: { summary: 'Deactivate App', tags: ['Apps'] },
|
|
})
|
|
|
|
.post('/api/apps/:appId/activate', async ({ params: { appId }, request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const app = await prisma.app.findUnique({ where: { id: appId } })
|
|
if (!app) { set.status = 404; return { error: 'App not found' } }
|
|
await prisma.app.update({ where: { id: appId }, data: { active: true } })
|
|
await createSystemLog(auth.userId, 'UPDATE', `Activated app: ${appId}`)
|
|
return { success: true }
|
|
}, {
|
|
params: t.Object({ appId: t.String() }),
|
|
detail: { summary: 'Activate App', tags: ['Apps'] },
|
|
})
|
|
|
|
.post('/api/apps/:appId/generate-key', async ({ params: { appId }, request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const app = await prisma.app.findUnique({ where: { id: appId } })
|
|
if (!app) { set.status = 404; return { error: 'App not found' } }
|
|
const key = `mapp_${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('hex')}`
|
|
await prisma.app.update({ where: { id: appId }, data: { clientApiKey: key } })
|
|
await createSystemLog(auth.userId, 'UPDATE', `Generated client API key for app: ${appId}`)
|
|
return { clientApiKey: key }
|
|
}, {
|
|
params: t.Object({ appId: t.String() }),
|
|
detail: { summary: 'Generate Client API Key', 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 dateFrom = query.dateFrom
|
|
const dateTo = query.dateTo
|
|
|
|
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
|
|
}
|
|
if (dateFrom || dateTo) {
|
|
where.createdAt = {}
|
|
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
|
|
if (dateTo) {
|
|
const end = new Date(dateTo)
|
|
end.setHours(23, 59, 59, 999)
|
|
where.createdAt.lte = end
|
|
}
|
|
}
|
|
|
|
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"' })),
|
|
dateFrom: t.Optional(t.String({ description: 'Filter dari tanggal (ISO string atau YYYY-MM-DD)' })),
|
|
dateTo: t.Optional(t.String({ description: 'Filter sampai tanggal (ISO string atau YYYY-MM-DD)' })),
|
|
}),
|
|
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 untuk akun yang dibuat manual: 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 (body.active === false) {
|
|
await prisma.session.deleteMany({ where: { userId: id } })
|
|
}
|
|
|
|
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('USER'), 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 source = query.source as any
|
|
const dateFrom = query.dateFrom
|
|
const dateTo = query.dateTo
|
|
|
|
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
|
|
}
|
|
if (source && source !== 'all') {
|
|
where.source = source
|
|
}
|
|
if (dateFrom || dateTo) {
|
|
where.createdAt = {}
|
|
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
|
|
if (dateTo) {
|
|
const end = new Date(dateTo)
|
|
end.setHours(23, 59, 59, 999)
|
|
where.createdAt.lte = end
|
|
}
|
|
}
|
|
|
|
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' })),
|
|
source: t.Optional(t.String({ description: 'Filter sumber: QC | SYSTEM | USER | all' })),
|
|
dateFrom: t.Optional(t.String({ description: 'Filter dari tanggal (YYYY-MM-DD)' })),
|
|
dateTo: t.Optional(t.String({ description: 'Filter sampai tanggal (YYYY-MM-DD)' })),
|
|
}),
|
|
detail: {
|
|
summary: 'List Bug Reports',
|
|
description: 'Mengembalikan daftar bug report dengan pagination, beserta data pelapor, gambar, dan riwayat status (BugLog). Mendukung filter berdasarkan aplikasi, status, source, dan tanggal.',
|
|
tags: ['Bugs'],
|
|
},
|
|
})
|
|
|
|
.post('/api/bugs', async ({ body, request, set }) => {
|
|
let auth = await checkAuth(request)
|
|
if (!auth) {
|
|
const xKey = request.headers.get('x-api-key')
|
|
const appId = (body as any).app
|
|
if (xKey && appId) {
|
|
const app = await prisma.app.findUnique({ where: { id: appId, active: true } })
|
|
if (app?.clientApiKey && app.clientApiKey === xKey) {
|
|
const developer = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
|
if (developer) auth = { actingUserId: developer.id, reporterUserId: null, isApiKey: true }
|
|
}
|
|
}
|
|
}
|
|
if (!auth) {
|
|
set.status = 401
|
|
return { error: 'Unauthorized: provide session cookie or valid X-API-Key' }
|
|
}
|
|
const { actingUserId, reporterUserId } = 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.imageUrls?.length ? {
|
|
createMany: { data: body.imageUrls.map(imageUrl => ({ imageUrl })) }
|
|
} : undefined,
|
|
logs: {
|
|
create: {
|
|
userId: actingUserId,
|
|
status: 'OPEN',
|
|
description: 'Bug reported initially.',
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
broadcastNotification({
|
|
type: 'new_bug',
|
|
bug: {
|
|
id: bug.id,
|
|
description: bug.description,
|
|
appId: bug.appId,
|
|
source: bug.source,
|
|
affectedVersion: bug.affectedVersion,
|
|
createdAt: bug.createdAt,
|
|
},
|
|
})
|
|
|
|
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',
|
|
})),
|
|
imageUrls: t.Optional(t.Array(t.String(), { description: 'URL gambar screenshot bug, maks 3 (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'],
|
|
},
|
|
})
|
|
|
|
// ─── Image Upload & Proxy ──────────────────────────
|
|
.post('/api/upload/image', async ({ body, request, set }) => {
|
|
const auth = await checkAuth(request)
|
|
if (!auth) {
|
|
set.status = 401
|
|
return { error: 'Unauthorized' }
|
|
}
|
|
const { file } = body
|
|
if (!file.type.startsWith('image/')) {
|
|
set.status = 400
|
|
return { error: 'Hanya file gambar yang diperbolehkan' }
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
set.status = 400
|
|
return { error: 'Ukuran file maksimal 5MB' }
|
|
}
|
|
const path = await uploadBugImage(file)
|
|
return { url: `/api/bugs/images?path=${encodeURIComponent(path)}` }
|
|
}, {
|
|
body: t.Object({
|
|
file: t.File({ description: 'File gambar screenshot bug (maks 5MB)' }),
|
|
}),
|
|
detail: {
|
|
summary: 'Upload Bug Image',
|
|
description: 'Upload gambar screenshot bug ke MinIO. Mengembalikan URL proxy yang dapat langsung dipakai sebagai imageUrl pada POST /api/bugs.',
|
|
tags: ['Bugs'],
|
|
},
|
|
})
|
|
|
|
.get('/api/bugs/images', async ({ query, set }) => {
|
|
try {
|
|
const downloadUrl = await getMinioDownloadUrl(query.path)
|
|
return new Response(null, { status: 302, headers: { Location: downloadUrl } })
|
|
} catch {
|
|
set.status = 404
|
|
return { error: 'Gambar tidak ditemukan' }
|
|
}
|
|
}, {
|
|
query: t.Object({
|
|
path: t.String({ description: 'Path file di Seafile (contoh: /bug-reports/uuid.jpg)' }),
|
|
}),
|
|
detail: {
|
|
summary: 'Get Bug Image',
|
|
description: 'Proxy gambar bug dari MinIO. Meredirect ke presigned download URL MinIO (valid 1 jam).',
|
|
tags: ['Bugs'],
|
|
},
|
|
})
|
|
|
|
// ─── Bug Statistics API ────────────────────────────
|
|
.get('/api/bugs/stats', async ({ query }) => {
|
|
const range = [7, 30, 90].includes(Number(query.range)) ? Number(query.range) : 7
|
|
const now = new Date()
|
|
const rangeStart = new Date(now.getTime() - range * 24 * 60 * 60 * 1000)
|
|
|
|
const [totalBugs, openBugs, statusGroups, appGroups, sourceGroups, resolvedBugs, trendData] = await Promise.all([
|
|
prisma.bug.count(),
|
|
prisma.bug.count({ where: { status: 'OPEN' } }),
|
|
prisma.bug.groupBy({ by: ['status'], _count: { id: true } }),
|
|
prisma.bug.groupBy({ by: ['appId'], _count: { id: true } }),
|
|
prisma.bug.groupBy({ by: ['source'], _count: { id: true } }),
|
|
prisma.bug.findMany({
|
|
where: { status: { in: ['RESOLVED', 'CLOSED'] } },
|
|
select: { createdAt: true, updatedAt: true },
|
|
}),
|
|
prisma.bug.findMany({
|
|
where: { createdAt: { gte: rangeStart } },
|
|
select: { createdAt: true },
|
|
orderBy: { createdAt: 'asc' },
|
|
}),
|
|
])
|
|
|
|
const byStatus = Object.fromEntries(statusGroups.map((g) => [g.status, g._count.id]))
|
|
const byApp = appGroups.map((g) => ({ appId: g.appId, count: g._count.id }))
|
|
const bySource = Object.fromEntries(sourceGroups.map((g) => [g.source, g._count.id]))
|
|
|
|
const totalResolutionMs = resolvedBugs.reduce((sum, b) => sum + (b.updatedAt.getTime() - b.createdAt.getTime()), 0)
|
|
const avgResolutionHours = resolvedBugs.length > 0
|
|
? Math.round(totalResolutionMs / resolvedBugs.length / (1000 * 60 * 60) * 10) / 10
|
|
: 0
|
|
|
|
const resolvedCount = (byStatus['RESOLVED'] || 0) + (byStatus['CLOSED'] || 0)
|
|
const resolutionRate = totalBugs > 0 ? Math.round((resolvedCount / totalBugs) * 100) : 0
|
|
|
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
const trendMap: Record<string, number> = {}
|
|
const keyToLabel: Record<string, string> = {}
|
|
|
|
for (let i = 0; i < range; i++) {
|
|
const d = new Date(now)
|
|
d.setDate(d.getDate() - i)
|
|
const key = d.toISOString().slice(0, 10)
|
|
const label = `${d.getDate()} ${months[d.getMonth()]}`
|
|
keyToLabel[key] = label
|
|
trendMap[key] = 0
|
|
}
|
|
for (const b of trendData) {
|
|
const key = b.createdAt.toISOString().slice(0, 10)
|
|
if (key in trendMap) trendMap[key]++
|
|
}
|
|
const trend: { date: string; count: number }[] = []
|
|
for (let i = 0; i < range; i++) {
|
|
const d = new Date(now)
|
|
d.setDate(d.getDate() - i)
|
|
const key = d.toISOString().slice(0, 10)
|
|
trend.push({ date: keyToLabel[key] ?? key, count: trendMap[key] ?? 0 })
|
|
}
|
|
trend.reverse()
|
|
|
|
return {
|
|
totalBugs,
|
|
openBugs,
|
|
byStatus,
|
|
byApp,
|
|
bySource,
|
|
avgResolutionHours,
|
|
resolutionRate,
|
|
trend,
|
|
range,
|
|
}
|
|
}, {
|
|
query: t.Object({
|
|
range: t.Optional(t.String({ description: 'Rentang hari: 7, 30, atau 90 (default: 30)' })),
|
|
}),
|
|
detail: {
|
|
summary: 'Bug Statistics',
|
|
description: 'Statistik bug: total, distribusi status, per app, per source, avg resolution time, dan trend.',
|
|
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'],
|
|
},
|
|
})
|
|
.get('/api/system/version', async () => {
|
|
const pkg = await Bun.file('./package.json').json()
|
|
let commit = 'unknown'
|
|
let branch = 'unknown'
|
|
let changelog: { hash: string; date: string; author: string; message: string }[] = []
|
|
try {
|
|
const commitProc = Bun.spawn(['git', 'rev-parse', '--short', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' })
|
|
commit = (await new Response(commitProc.stdout).text()).trim()
|
|
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' })
|
|
branch = (await new Response(branchProc.stdout).text()).trim()
|
|
const logProc = Bun.spawn(
|
|
['git', 'log', '--pretty=format:%h|%aI|%an|%s', '-20'],
|
|
{ stdout: 'pipe', stderr: 'pipe' },
|
|
)
|
|
const logText = (await new Response(logProc.stdout).text()).trim()
|
|
changelog = logText.split('\n').filter(Boolean).map(line => {
|
|
const [hash, date, author, ...msgParts] = line.split('|')
|
|
return { hash, date, author, message: msgParts.join('|') }
|
|
})
|
|
} catch { /* git not available */ }
|
|
return {
|
|
version: pkg.version as string,
|
|
commit,
|
|
branch,
|
|
changelog,
|
|
}
|
|
}, {
|
|
detail: {
|
|
summary: 'Version Info',
|
|
description: 'Mengembalikan versi aplikasi, git commit hash, branch aktif, dan 20 commit terakhir sebagai changelog.',
|
|
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'] },
|
|
})
|
|
|
|
// ─── Dev Console Admin API (DEVELOPER only) ────────
|
|
|
|
.get('/api/admin/stats', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const [totalApps, openBugs, totalOperators] = await Promise.all([
|
|
prisma.app.count(),
|
|
prisma.bug.count({ where: { status: 'OPEN' } }),
|
|
prisma.user.count(),
|
|
])
|
|
const onlineCount = getOnlineUserIds().length
|
|
return { totalApps, openBugs, totalOperators, onlineOperators: onlineCount }
|
|
})
|
|
|
|
.get('/api/admin/users', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const users = await prisma.user.findMany({
|
|
select: { id: true, name: true, email: true, role: true, active: true, image: true, createdAt: true },
|
|
orderBy: { createdAt: 'asc' },
|
|
})
|
|
return { users }
|
|
})
|
|
|
|
.put('/api/admin/users/:id/role', async ({ request, params, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
if (auth.userId === params.id) { set.status = 400; return { error: 'Tidak bisa mengubah role sendiri' } }
|
|
const { role } = (await request.json()) as { role: string }
|
|
if (!['USER', 'ADMIN'].includes(role)) { set.status = 400; return { error: 'Role tidak valid (USER atau ADMIN)' } }
|
|
const target = await prisma.user.findUnique({ where: { id: params.id }, select: { role: true } })
|
|
if (target?.role === 'DEVELOPER') { set.status = 400; return { error: 'Tidak bisa mengubah role DEVELOPER' } }
|
|
const user = await prisma.user.update({
|
|
where: { id: params.id },
|
|
data: { role: role as 'USER' | 'ADMIN' },
|
|
select: { id: true, name: true, email: true, role: true, active: true, createdAt: true },
|
|
})
|
|
await appLog('info', `Role changed: ${user.email} ${target?.role} → ${role}`)
|
|
await createSystemLog(auth.userId, 'UPDATE', `Role changed: ${user.name} (${user.email}) ${target?.role} → ${role}`)
|
|
return { user }
|
|
})
|
|
|
|
.put('/api/admin/users/:id/activate', async ({ request, params, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
if (auth.userId === params.id) { set.status = 400; return { error: 'Tidak bisa mengubah status sendiri' } }
|
|
const { active } = (await request.json()) as { active: boolean }
|
|
const user = await prisma.user.update({
|
|
where: { id: params.id },
|
|
data: { active },
|
|
select: { id: true, name: true, email: true, role: true, active: true, createdAt: true },
|
|
})
|
|
if (!active) await prisma.session.deleteMany({ where: { userId: params.id } })
|
|
await appLog('info', `User ${active ? 'activated' : 'deactivated'}: ${user.email}`)
|
|
await createSystemLog(auth.userId, active ? 'UPDATE' : 'DELETE', `User ${active ? 'activated' : 'deactivated'}: ${user.name} (${user.email})`)
|
|
return { user }
|
|
})
|
|
|
|
.ws('/ws/presence', {
|
|
async open(ws) {
|
|
const cookie = ws.data.headers?.cookie ?? ''
|
|
const token = (cookie as string).match(/session=([^;]+)/)?.[1]
|
|
if (!token) { ws.close(4001, 'Unauthorized'); return }
|
|
const session = await prisma.session.findUnique({
|
|
where: { token },
|
|
include: { user: { select: { id: true, role: true } } },
|
|
})
|
|
if (!session || session.expiresAt < new Date()) { ws.close(4001, 'Unauthorized'); return }
|
|
const role = session.user.role
|
|
const isAdmin = role === 'DEVELOPER'
|
|
const canReceiveNotifs = role === 'DEVELOPER' || role === 'ADMIN'
|
|
;(ws.data as unknown as { userId: string }).userId = session.user.id
|
|
addConnection(ws as any, session.user.id, isAdmin, canReceiveNotifs)
|
|
},
|
|
close(ws) { removeConnection(ws as any) },
|
|
message() {},
|
|
})
|
|
|
|
.get('/api/admin/presence', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
return { online: getOnlineUserIds() }
|
|
})
|
|
|
|
.get('/api/admin/logs/app', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const url = new URL(request.url)
|
|
const level = url.searchParams.get('level') as any
|
|
const limit = parseInt(url.searchParams.get('limit') ?? '100', 10)
|
|
const afterId = parseInt(url.searchParams.get('afterId') ?? '0', 10)
|
|
if (!env.REDIS_URL) return { logs: [], redisDisabled: true }
|
|
return { logs: await getAppLogs({ level: level || undefined, limit, afterId: afterId || undefined }) }
|
|
})
|
|
|
|
.delete('/api/admin/logs/app', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
await clearAppLogs()
|
|
return { ok: true }
|
|
})
|
|
|
|
.get('/api/admin/logs/audit', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const url = new URL(request.url)
|
|
const userId = url.searchParams.get('userId')
|
|
const type = url.searchParams.get('type')
|
|
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 500)
|
|
const where: Record<string, any> = {}
|
|
if (userId) where.userId = userId
|
|
if (type) where.type = type
|
|
const logs = await prisma.log.findMany({
|
|
where,
|
|
include: { user: { select: { name: true, email: true } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: limit,
|
|
})
|
|
return { logs }
|
|
})
|
|
|
|
.delete('/api/admin/logs/audit', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const { count } = await prisma.log.deleteMany()
|
|
await appLog('info', `Activity logs cleared manually (${count} entries)`)
|
|
return { ok: true, deleted: count }
|
|
})
|
|
|
|
.get('/api/admin/schema', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const fs = await import('node:fs')
|
|
const schemaPath = `${process.cwd()}/prisma/schema.prisma`
|
|
if (!fs.existsSync(schemaPath)) { set.status = 404; return { error: 'Schema not found' } }
|
|
const raw = fs.readFileSync(schemaPath, 'utf-8')
|
|
return { schema: parseSchema(raw) }
|
|
})
|
|
|
|
.get('/api/admin/routes', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const routes: { method: string; path: string; auth: string; category: string; description: string }[] = [
|
|
{ method: 'PAGE', path: '/', auth: 'public', category: 'frontend', description: 'Landing page' },
|
|
{ method: 'PAGE', path: '/login', auth: 'public', category: 'frontend', description: 'Login page (email/password + Google OAuth)' },
|
|
{ method: 'PAGE', path: '/dev', auth: 'developer', category: 'frontend', description: 'Dev console (DEVELOPER only)' },
|
|
{ method: 'PAGE', path: '/dashboard', auth: 'admin', category: 'frontend', description: 'Admin dashboard (ADMIN/DEVELOPER)' },
|
|
{ method: 'PAGE', path: '/apps', auth: 'admin', category: 'frontend', description: 'App list' },
|
|
{ method: 'PAGE', path: '/apps/:appId', auth: 'admin', category: 'frontend', description: 'App detail (errors, logs, users, etc.)' },
|
|
{ method: 'PAGE', path: '/bug-reports', auth: 'admin', category: 'frontend', description: 'Bug reports management' },
|
|
{ method: 'PAGE', path: '/logs', auth: 'admin', category: 'frontend', description: 'Activity logs' },
|
|
{ method: 'PAGE', path: '/users', auth: 'admin', category: 'frontend', description: 'Operator management' },
|
|
{ method: 'PAGE', path: '/profile', auth: 'authenticated', category: 'frontend', description: 'User profile' },
|
|
{ method: 'POST', path: '/api/auth/login', auth: 'public', category: 'auth', description: 'Email/password login' },
|
|
{ method: 'POST', path: '/api/auth/logout', auth: 'authenticated', category: 'auth', description: 'Logout' },
|
|
{ method: 'GET', path: '/api/auth/session', auth: 'public', category: 'auth', description: 'Check current session' },
|
|
{ method: 'GET', path: '/api/auth/google', auth: 'public', category: 'auth', description: 'Google OAuth redirect' },
|
|
{ method: 'GET', path: '/api/auth/callback/google', auth: 'public', category: 'auth', description: 'Google OAuth callback' },
|
|
{ method: 'GET', path: '/api/dashboard/stats', auth: 'authenticated', category: 'dashboard', description: 'Dashboard statistics' },
|
|
{ method: 'GET', path: '/api/dashboard/recent-errors', auth: 'authenticated', category: 'dashboard', description: 'Recent bug reports' },
|
|
{ method: 'GET', path: '/api/apps', auth: 'authenticated', category: 'apps', description: 'List monitored apps' },
|
|
{ method: 'GET', path: '/api/apps/:appId', auth: 'authenticated', category: 'apps', description: 'Get app details' },
|
|
{ method: 'GET', path: '/api/bugs', auth: 'authenticated', category: 'bugs', description: 'List bug reports' },
|
|
{ method: 'POST', path: '/api/bugs', auth: 'apiKeyOrSession', category: 'bugs', description: 'Create bug report' },
|
|
{ method: 'PATCH', path: '/api/bugs/:id/status', auth: 'authenticated', category: 'bugs', description: 'Update bug status' },
|
|
{ method: 'PATCH', path: '/api/bugs/:id/feedback', auth: 'authenticated', category: 'bugs', description: 'Update bug feedback' },
|
|
{ method: 'POST', path: '/api/upload/image', auth: 'apiKeyOrSession', category: 'bugs', description: 'Upload bug screenshot' },
|
|
{ method: 'GET', path: '/api/bugs/images', auth: 'public', category: 'bugs', description: 'Proxy bug image from MinIO' },
|
|
{ method: 'GET', path: '/api/logs', auth: 'authenticated', category: 'logs', description: 'List activity logs' },
|
|
{ method: 'POST', path: '/api/logs', auth: 'authenticated', category: 'logs', description: 'Create activity log' },
|
|
{ method: 'GET', path: '/api/logs/operators', auth: 'authenticated', category: 'logs', description: 'Operators list for log filter' },
|
|
{ method: 'GET', path: '/api/operators', auth: 'authenticated', category: 'operators', description: 'List operators' },
|
|
{ method: 'GET', path: '/api/operators/stats', auth: 'authenticated', category: 'operators', description: 'Operator stats' },
|
|
{ method: 'POST', path: '/api/operators', auth: 'authenticated', category: 'operators', description: 'Create operator' },
|
|
{ method: 'PATCH', path: '/api/operators/:id', auth: 'authenticated', category: 'operators', description: 'Update operator' },
|
|
{ method: 'DELETE', path: '/api/operators/:id', auth: 'authenticated', category: 'operators', description: 'Deactivate operator' },
|
|
{ method: 'GET', path: '/api/admin/stats', auth: 'developer', category: 'admin', description: 'Dev console overview stats' },
|
|
{ method: 'GET', path: '/api/admin/users', auth: 'developer', category: 'admin', description: 'List all users' },
|
|
{ method: 'PUT', path: '/api/admin/users/:id/role', auth: 'developer', category: 'admin', description: 'Change user role' },
|
|
{ method: 'PUT', path: '/api/admin/users/:id/activate', auth: 'developer', category: 'admin', description: 'Activate/deactivate user' },
|
|
{ method: 'GET', path: '/api/admin/presence', auth: 'developer', category: 'admin', description: 'Online user IDs' },
|
|
{ method: 'GET', path: '/api/admin/logs/app', auth: 'developer', category: 'admin', description: 'App logs (Redis)' },
|
|
{ method: 'GET', path: '/api/admin/logs/audit', auth: 'developer', category: 'admin', description: 'Activity logs (DB)' },
|
|
{ method: 'DELETE', path: '/api/admin/logs/app', auth: 'developer', category: 'admin', description: 'Clear app logs' },
|
|
{ method: 'DELETE', path: '/api/admin/logs/audit', auth: 'developer', category: 'admin', description: 'Clear activity logs' },
|
|
{ method: 'GET', path: '/api/admin/schema', auth: 'developer', category: 'admin', description: 'Database schema (Prisma)' },
|
|
{ method: 'GET', path: '/api/admin/routes', auth: 'developer', category: 'admin', description: 'Routes metadata' },
|
|
{ method: 'GET', path: '/api/admin/project-structure', auth: 'developer', category: 'admin', description: 'Project file structure' },
|
|
{ method: 'GET', path: '/api/admin/env-map', auth: 'developer', category: 'admin', description: 'Environment variables map' },
|
|
{ method: 'GET', path: '/api/admin/test-coverage', auth: 'developer', category: 'admin', description: 'Test coverage mapping' },
|
|
{ method: 'GET', path: '/api/admin/dependencies', auth: 'developer', category: 'admin', description: 'NPM dependencies graph' },
|
|
{ method: 'GET', path: '/api/admin/migrations', auth: 'developer', category: 'admin', description: 'Migration timeline' },
|
|
{ method: 'GET', path: '/api/admin/sessions', auth: 'developer', category: 'admin', description: 'Active sessions' },
|
|
{ method: 'WS', path: '/ws/presence', auth: 'authenticated', category: 'realtime', description: 'Real-time presence tracking' },
|
|
{ method: 'GET', path: '/health', auth: 'public', category: 'utility', description: 'Health check' },
|
|
]
|
|
const byMethod: Record<string, number> = {}
|
|
const byAuth: Record<string, number> = {}
|
|
const byCategory: Record<string, number> = {}
|
|
for (const r of routes) {
|
|
byMethod[r.method] = (byMethod[r.method] || 0) + 1
|
|
byAuth[r.auth] = (byAuth[r.auth] || 0) + 1
|
|
byCategory[r.category] = (byCategory[r.category] || 0) + 1
|
|
}
|
|
return { routes, summary: { total: routes.length, byMethod, byAuth, byCategory } }
|
|
})
|
|
|
|
.get('/api/admin/project-structure', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const fs = await import('node:fs')
|
|
const path = await import('node:path')
|
|
const root = process.cwd()
|
|
const scanDirs = ['src', 'prisma', 'tests']
|
|
const skipDirs = new Set(['node_modules', 'dist', 'generated', '.git', '.next'])
|
|
const exts = new Set(['.ts', '.tsx'])
|
|
interface FileInfo { path: string; category: string; lines: number; exports: string[]; imports: { from: string; names: string[] }[] }
|
|
interface DirInfo { path: string; category: string; fileCount: number }
|
|
const files: FileInfo[] = []
|
|
const dirs: DirInfo[] = []
|
|
function categorize(filePath: string): string {
|
|
if (filePath.startsWith('src/frontend/routes/')) return 'route'
|
|
if (filePath.startsWith('src/frontend/hooks/')) return 'hook'
|
|
if (filePath.startsWith('src/frontend/components/')) return 'component'
|
|
if (filePath.startsWith('src/frontend')) return 'frontend'
|
|
if (filePath.startsWith('src/lib/')) return 'lib'
|
|
if (filePath.startsWith('prisma/')) return 'prisma'
|
|
if (filePath.startsWith('tests/unit/')) return 'test-unit'
|
|
if (filePath.startsWith('tests/integration/')) return 'test-integration'
|
|
if (filePath.startsWith('tests/')) return 'test'
|
|
if (filePath.startsWith('src/')) return 'backend'
|
|
return 'config'
|
|
}
|
|
function parseFile(filePath: string, content: string): FileInfo {
|
|
const lines = content.split('\n').length
|
|
const exports: string[] = []
|
|
const imports: { from: string; names: string[] }[] = []
|
|
for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|const|let|var|class|type|interface|enum)\s+(\w+)/g)) exports.push(m[1])
|
|
for (const m of content.matchAll(/import\s+(?:\{([^}]+)\}|(\w+))(?:\s*,\s*\{([^}]+)\})?\s+from\s+['"]([^'"]+)['"]/g)) {
|
|
const names: string[] = []
|
|
if (m[1]) names.push(...m[1].split(',').map((s) => s.trim().split(' as ')[0].trim()).filter(Boolean))
|
|
if (m[2]) names.push(m[2])
|
|
if (m[3]) names.push(...m[3].split(',').map((s) => s.trim().split(' as ')[0].trim()).filter(Boolean))
|
|
let from = m[4]
|
|
if (from.startsWith('.')) {
|
|
const dir = path.dirname(filePath)
|
|
from = path.normalize(path.join(dir, from)).replace(/\\/g, '/')
|
|
for (const ext of ['.ts', '.tsx', '/index.ts', '/index.tsx']) {
|
|
if (fs.existsSync(path.join(root, from + ext))) { from = from + ext; break }
|
|
if (fs.existsSync(path.join(root, from))) break
|
|
}
|
|
}
|
|
imports.push({ from, names })
|
|
}
|
|
return { path: filePath, category: categorize(filePath), lines, exports, imports }
|
|
}
|
|
function scan(dir: string) {
|
|
const absDir = path.join(root, dir)
|
|
if (!fs.existsSync(absDir)) return
|
|
const entries = fs.readdirSync(absDir, { withFileTypes: true })
|
|
let fileCount = 0
|
|
for (const entry of entries) {
|
|
if (skipDirs.has(entry.name)) continue
|
|
const rel = path.join(dir, entry.name).replace(/\\/g, '/')
|
|
if (entry.isDirectory()) scan(rel)
|
|
else if (exts.has(path.extname(entry.name))) { files.push(parseFile(rel, fs.readFileSync(path.join(root, rel), 'utf-8'))); fileCount++ }
|
|
}
|
|
dirs.push({ path: dir, category: categorize(`${dir}/`), fileCount })
|
|
}
|
|
for (const d of scanDirs) scan(d)
|
|
files.sort((a, b) => a.path.localeCompare(b.path))
|
|
dirs.sort((a, b) => a.path.localeCompare(b.path))
|
|
const totalLines = files.reduce((s, f) => s + f.lines, 0)
|
|
const totalExports = files.reduce((s, f) => s + f.exports.length, 0)
|
|
const totalImports = files.reduce((s, f) => s + f.imports.length, 0)
|
|
const byCategory: Record<string, number> = {}
|
|
for (const f of files) byCategory[f.category] = (byCategory[f.category] || 0) + 1
|
|
return { files, directories: dirs, summary: { totalFiles: files.length, totalLines, totalExports, totalImports, byCategory } }
|
|
})
|
|
|
|
.get('/api/admin/env-map', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const fs = await import('node:fs')
|
|
const path = await import('node:path')
|
|
const root = process.cwd()
|
|
const envDefs: { name: string; envKey: string; required: boolean; default: string | null; category: string; description: string }[] = [
|
|
{ name: 'DATABASE_URL', envKey: 'DATABASE_URL', required: true, default: null, category: 'database', description: 'PostgreSQL connection string' },
|
|
{ name: 'REDIS_URL', envKey: 'REDIS_URL', required: false, default: '(empty)', category: 'cache', description: 'Redis connection string (optional, enables App Logs)' },
|
|
{ name: 'GOOGLE_CLIENT_ID', envKey: 'GOOGLE_CLIENT_ID', required: true, default: null, category: 'auth', description: 'Google OAuth client ID' },
|
|
{ name: 'GOOGLE_CLIENT_SECRET', envKey: 'GOOGLE_CLIENT_SECRET', required: true, default: null, category: 'auth', description: 'Google OAuth client secret' },
|
|
{ name: 'SUPER_ADMIN_EMAIL', envKey: 'SUPER_ADMIN_EMAIL', required: false, default: '(empty)', category: 'auth', description: 'Emails to auto-promote to DEVELOPER role' },
|
|
{ name: 'API_KEY', envKey: 'API_KEY', required: true, default: null, category: 'auth', description: 'API key for external clients (mobile app)' },
|
|
{ name: 'MINIO_ENDPOINT', envKey: 'MINIO_ENDPOINT', required: true, default: null, category: 'storage', description: 'MinIO server endpoint' },
|
|
{ name: 'MINIO_PORT', envKey: 'MINIO_PORT', required: false, default: '443', category: 'storage', description: 'MinIO server port' },
|
|
{ name: 'MINIO_USE_SSL', envKey: 'MINIO_USE_SSL', required: false, default: 'true', category: 'storage', description: 'Use SSL for MinIO connection' },
|
|
{ name: 'MINIO_ACCESS_KEY', envKey: 'MINIO_ACCESS_KEY', required: true, default: null, category: 'storage', description: 'MinIO access key' },
|
|
{ name: 'MINIO_SECRET_KEY', envKey: 'MINIO_SECRET_KEY', required: true, default: null, category: 'storage', description: 'MinIO secret key' },
|
|
{ name: 'MINIO_BUCKET', envKey: 'MINIO_BUCKET', required: true, default: null, category: 'storage', description: 'MinIO bucket name' },
|
|
{ name: 'MINIO_UPLOAD_DIR', envKey: 'MINIO_UPLOAD_DIR', required: false, default: 'bug-reports', category: 'storage', description: 'MinIO upload directory prefix' },
|
|
{ name: 'PORT', envKey: 'PORT', required: false, default: '3000', category: 'app', description: 'Server port' },
|
|
{ name: 'NODE_ENV', envKey: 'NODE_ENV', required: false, default: 'development', category: 'app', description: 'Environment mode' },
|
|
{ name: 'REACT_EDITOR', envKey: 'REACT_EDITOR', required: false, default: 'code', category: 'app', description: 'Editor for click-to-source' },
|
|
{ name: 'BUN_PUBLIC_BASE_URL', envKey: 'BUN_PUBLIC_BASE_URL', required: false, default: 'http://localhost:3000', category: 'app', description: 'Public base URL (for OAuth redirect)' },
|
|
]
|
|
const srcFiles = ['src/lib/env.ts', 'src/lib/db.ts', 'src/lib/redis.ts', 'src/app.ts', 'src/index.tsx']
|
|
const fileContents: Record<string, string> = {}
|
|
for (const f of srcFiles) {
|
|
const absPath = path.join(root, f)
|
|
if (fs.existsSync(absPath)) fileContents[f] = fs.readFileSync(absPath, 'utf-8')
|
|
}
|
|
const variables = envDefs.map((def) => {
|
|
const usedBy: string[] = []
|
|
for (const [file, content] of Object.entries(fileContents)) {
|
|
if (content.includes(def.envKey) || content.includes(`env.${def.name}`)) usedBy.push(file)
|
|
}
|
|
return { name: def.name, required: def.required, isSet: !!process.env[def.envKey], default: def.default, category: def.category, description: def.description, usedBy }
|
|
})
|
|
const byCategory: Record<string, number> = {}
|
|
let setCount = 0, requiredCount = 0
|
|
for (const v of variables) {
|
|
byCategory[v.category] = (byCategory[v.category] || 0) + 1
|
|
if (v.isSet) setCount++
|
|
if (v.required) requiredCount++
|
|
}
|
|
return { variables, summary: { total: variables.length, set: setCount, unset: variables.length - setCount, required: requiredCount, byCategory } }
|
|
})
|
|
|
|
.get('/api/admin/test-coverage', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const fs = await import('node:fs')
|
|
const pathMod = await import('node:path')
|
|
const root = process.cwd()
|
|
const exts = new Set(['.ts', '.tsx'])
|
|
const skipDirs = new Set(['node_modules', 'dist', 'generated', '.git'])
|
|
interface SrcFile { path: string; lines: number; exports: string[]; testedBy: string[]; coverage: string }
|
|
interface TestFile { path: string; lines: number; type: string; targets: string[] }
|
|
function scanDir(dir: string, collect: string[]) {
|
|
const abs = pathMod.join(root, dir)
|
|
if (!fs.existsSync(abs)) return
|
|
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
if (skipDirs.has(entry.name)) continue
|
|
const rel = pathMod.join(dir, entry.name).replace(/\\/g, '/')
|
|
if (entry.isDirectory()) scanDir(rel, collect)
|
|
else if (exts.has(pathMod.extname(entry.name))) collect.push(rel)
|
|
}
|
|
}
|
|
const srcPaths: string[] = []
|
|
scanDir('src', srcPaths)
|
|
const srcFiltered = srcPaths.filter((f) => !f.includes('routeTree.gen'))
|
|
const testPaths: string[] = []
|
|
scanDir('tests', testPaths)
|
|
const testFiltered = testPaths.filter((f) => f.includes('.test.'))
|
|
const testFiles: TestFile[] = testFiltered.map((tp) => {
|
|
const content = fs.readFileSync(pathMod.join(root, tp), 'utf-8')
|
|
const lines = content.split('\n').length
|
|
const type = tp.includes('/unit/') ? 'unit' : tp.includes('/integration/') ? 'integration' : 'other'
|
|
const targets: string[] = []
|
|
for (const m of content.matchAll(/from\s+['"]([^'"]*(?:src|lib)[^'"]*)['"]/g)) {
|
|
let resolved = m[1].replace(/^.*?src\//, 'src/')
|
|
if (resolved.startsWith('.')) resolved = pathMod.normalize(pathMod.join(pathMod.dirname(tp), resolved)).replace(/\\/g, '/')
|
|
for (const ext of ['', '.ts', '.tsx']) {
|
|
const full = resolved + ext
|
|
if (srcFiltered.includes(full)) { targets.push(full); break }
|
|
}
|
|
}
|
|
if (/fetch\(['"`]\/api\//.test(content) || /createApp|createTestApp/.test(content)) {
|
|
if (!targets.includes('src/app.ts')) targets.push('src/app.ts')
|
|
}
|
|
return { path: tp, lines, type, targets: [...new Set(targets)] }
|
|
})
|
|
const testedByMap: Record<string, string[]> = {}
|
|
for (const t of testFiles) for (const target of t.targets) { if (!testedByMap[target]) testedByMap[target] = []; testedByMap[target].push(t.path) }
|
|
const sourceFiles: SrcFile[] = srcFiltered.map((sp) => {
|
|
const content = fs.readFileSync(pathMod.join(root, sp), 'utf-8')
|
|
const lines = content.split('\n').length
|
|
const exports: string[] = []
|
|
for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|const|let|var|class|type|interface|enum)\s+(\w+)/g)) exports.push(m[1])
|
|
const tb = testedByMap[sp] || []
|
|
const coverage = tb.length === 0 ? 'uncovered' : tb.some((t) => t.includes('/unit/')) ? 'covered' : 'partial'
|
|
return { path: sp, lines, exports, testedBy: tb, coverage }
|
|
})
|
|
const covered = sourceFiles.filter((f) => f.coverage === 'covered').length
|
|
const partial = sourceFiles.filter((f) => f.coverage === 'partial').length
|
|
const uncovered = sourceFiles.filter((f) => f.coverage === 'uncovered').length
|
|
return { sourceFiles, testFiles, summary: { totalSource: sourceFiles.length, totalTests: testFiles.length, covered, partial, uncovered, coveragePercent: Math.round(((covered + partial * 0.5) / sourceFiles.length) * 100) } }
|
|
})
|
|
|
|
.get('/api/admin/dependencies', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const fs = await import('node:fs')
|
|
const pathMod = await import('node:path')
|
|
const root = process.cwd()
|
|
const pkgPath = pathMod.join(root, 'package.json')
|
|
if (!fs.existsSync(pkgPath)) { set.status = 404; return { error: 'package.json not found' } }
|
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
const deps: Record<string, string> = pkg.dependencies || {}
|
|
const devDeps: Record<string, string> = pkg.devDependencies || {}
|
|
const catMap: Record<string, string> = {
|
|
elysia: 'server', '@elysiajs/cors': 'server', '@elysiajs/html': 'server', '@elysiajs/swagger': 'server',
|
|
react: 'ui', 'react-dom': 'ui', '@mantine/core': 'ui', '@mantine/hooks': 'ui', '@mantine/charts': 'ui',
|
|
'@mantine/notifications': 'ui', '@mantine/modals': 'ui', '@tanstack/react-router': 'ui',
|
|
'@tanstack/react-query': 'ui', '@xyflow/react': 'ui', 'react-icons': 'ui', recharts: 'ui', swr: 'ui',
|
|
'@prisma/client': 'database', prisma: 'database', minio: 'storage',
|
|
vite: 'build', typescript: 'build', '@biomejs/biome': 'build', '@vitejs/plugin-react': 'build', elkjs: 'build',
|
|
}
|
|
const srcFiles: string[] = []
|
|
function scanSrc(dir: string) {
|
|
const abs = pathMod.join(root, dir)
|
|
if (!fs.existsSync(abs)) return
|
|
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
if (['node_modules', 'dist', 'generated', '.git'].includes(e.name)) continue
|
|
const rel = pathMod.join(dir, e.name).replace(/\\/g, '/')
|
|
if (e.isDirectory()) scanSrc(rel)
|
|
else if (/\.(ts|tsx)$/.test(e.name)) srcFiles.push(rel)
|
|
}
|
|
}
|
|
scanSrc('src')
|
|
const fileContents: Record<string, string> = {}
|
|
for (const f of srcFiles) fileContents[f] = fs.readFileSync(pathMod.join(root, f), 'utf-8')
|
|
const allPkgs: { name: string; version: string; type: string; category: string; usedBy: string[] }[] = []
|
|
for (const [name, version] of Object.entries(deps)) {
|
|
const usedBy: string[] = []
|
|
const importPattern = new RegExp(`from\\s+['"]${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
|
|
for (const [file, content] of Object.entries(fileContents)) if (importPattern.test(content)) usedBy.push(file)
|
|
allPkgs.push({ name, version, type: 'runtime', category: catMap[name] || 'other', usedBy })
|
|
}
|
|
for (const [name, version] of Object.entries(devDeps)) allPkgs.push({ name, version, type: 'dev', category: catMap[name] || 'build', usedBy: [] })
|
|
const byCategory: Record<string, number> = {}
|
|
let runtime = 0, dev = 0
|
|
for (const p of allPkgs) { byCategory[p.category] = (byCategory[p.category] || 0) + 1; if (p.type === 'runtime') runtime++; else dev++ }
|
|
return { packages: allPkgs, summary: { total: allPkgs.length, runtime, dev, byCategory } }
|
|
})
|
|
|
|
.get('/api/admin/migrations', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const fs = await import('node:fs')
|
|
const pathMod = await import('node:path')
|
|
const root = process.cwd()
|
|
const migrationsDir = pathMod.join(root, 'prisma/migrations')
|
|
if (!fs.existsSync(migrationsDir)) return { migrations: [], summary: { totalMigrations: 0, firstMigration: null, lastMigration: null, totalChanges: 0 } }
|
|
const entries = fs.readdirSync(migrationsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && /^\d{14}_/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name))
|
|
const migrations = entries.map((entry) => {
|
|
const sqlPath = pathMod.join(migrationsDir, entry.name, 'migration.sql')
|
|
let sql = ''
|
|
const changes: string[] = []
|
|
if (fs.existsSync(sqlPath)) {
|
|
sql = fs.readFileSync(sqlPath, 'utf-8')
|
|
for (const m of sql.matchAll(/^(CREATE TABLE|ALTER TABLE|CREATE INDEX|CREATE UNIQUE INDEX|DROP TABLE|DROP INDEX|CREATE TYPE|ALTER TYPE)\s+["']?(\w+)["']?/gim)) changes.push(`${m[1]} ${m[2]}`)
|
|
for (const m of sql.matchAll(/CREATE TYPE\s+"(\w+)"/g)) if (!changes.some((c) => c.includes(m[1]))) changes.push(`CREATE TYPE ${m[1]}`)
|
|
}
|
|
const dateStr = entry.name.substring(0, 14)
|
|
const createdAt = new Date(`${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}T${dateStr.slice(8, 10)}:${dateStr.slice(10, 12)}:${dateStr.slice(12, 14)}.000Z`).toISOString()
|
|
const name = entry.name.substring(15)
|
|
return { name, folder: entry.name, createdAt, changes, sql: sql.substring(0, 800) }
|
|
})
|
|
const totalChanges = migrations.reduce((s, m) => s + m.changes.length, 0)
|
|
return { migrations, summary: { totalMigrations: migrations.length, firstMigration: migrations[0]?.createdAt || null, lastMigration: migrations[migrations.length - 1]?.createdAt || null, totalChanges } }
|
|
})
|
|
|
|
.get('/api/admin/sessions', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const onlineIds = new Set(getOnlineUserIds())
|
|
const sessions = await prisma.session.findMany({
|
|
include: { user: { select: { id: true, name: true, email: true, role: true, active: true } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
const now = new Date()
|
|
const result = sessions.map((s) => ({
|
|
id: s.id, userId: s.user.id, userName: s.user.name, userEmail: s.user.email,
|
|
userRole: s.user.role, userActive: s.user.active,
|
|
isOnline: onlineIds.has(s.user.id),
|
|
createdAt: s.createdAt.toISOString(), expiresAt: s.expiresAt.toISOString(), isExpired: s.expiresAt < now,
|
|
}))
|
|
const byRole: Record<string, number> = {}
|
|
const uniqueUsers = new Set<string>()
|
|
let active = 0, expired = 0
|
|
for (const s of result) {
|
|
uniqueUsers.add(s.userId)
|
|
byRole[s.userRole] = (byRole[s.userRole] || 0) + 1
|
|
if (s.isExpired) expired++; else active++
|
|
}
|
|
return { sessions: result, summary: { totalSessions: result.length, activeSessions: active, expiredSessions: expired, onlineUsers: onlineIds.size, byRole } }
|
|
})
|
|
|
|
// ─── API Keys (proxied to desa-plus /api/monitoring/api-keys) ─────────────
|
|
|
|
.get('/api/admin/api-keys', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
|
|
if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi' } }
|
|
const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys`, {
|
|
headers: { 'x-api-key': app.apiKey ?? '' },
|
|
})
|
|
const json = await res.json()
|
|
return { keys: json.data ?? [] }
|
|
})
|
|
|
|
.get('/api/admin/api-keys/:id', async ({ request, set, params }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
|
|
if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi' } }
|
|
const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys/${params.id}`, {
|
|
headers: { 'x-api-key': app.apiKey ?? '' },
|
|
})
|
|
const json = await res.json()
|
|
set.status = res.status
|
|
return json
|
|
})
|
|
|
|
.post('/api/admin/api-keys', async ({ request, set }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const body = await request.json() as { name?: string }
|
|
if (!body.name?.trim()) { set.status = 400; return { error: 'name wajib diisi' } }
|
|
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
|
|
if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi: urlApi kosong' } }
|
|
if (!app?.apiKey) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi: apiKey kosong' } }
|
|
try {
|
|
const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-api-key': app.apiKey },
|
|
body: JSON.stringify({ name: body.name.trim() }),
|
|
})
|
|
const json = await res.json()
|
|
set.status = res.status
|
|
return { key: json.data ?? null }
|
|
} catch (e) {
|
|
set.status = 502
|
|
return { error: `Gagal menghubungi desa-plus: ${String(e)}` }
|
|
}
|
|
})
|
|
|
|
.patch('/api/admin/api-keys/:id', async ({ request, set, params }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const body = await request.json() as { isActive?: boolean }
|
|
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
|
|
if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi' } }
|
|
const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys/${params.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json', 'x-api-key': app.apiKey ?? '' },
|
|
body: JSON.stringify({ isActive: body.isActive }),
|
|
})
|
|
const json = await res.json()
|
|
set.status = res.status
|
|
return json
|
|
})
|
|
|
|
.delete('/api/admin/api-keys/:id', async ({ request, set, params }) => {
|
|
const auth = await requireDeveloper(request, set)
|
|
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
|
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
|
|
if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi' } }
|
|
const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys/${params.id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'x-api-key': app.apiKey ?? '' },
|
|
})
|
|
const json = await res.json()
|
|
set.status = res.status
|
|
return json
|
|
})
|
|
|
|
// ─── Desa Plus Proxy ───────────────────────────────────────────────────────
|
|
|
|
.all('/api/proxy/desa-plus/*', async ({ request, set }) => {
|
|
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
|
|
if (!app?.urlApi) { set.status = 503; return { error: 'urlApi belum dikonfigurasi untuk app desa-plus.' } }
|
|
const base = app.urlApi.replace(/\/$/, '')
|
|
const url = new URL(request.url)
|
|
const upstream = `${base}${url.pathname.replace('/api/proxy/desa-plus', '')}${url.search}`
|
|
const headers = new Headers(request.headers)
|
|
headers.delete('host')
|
|
if (app.apiKey) headers.set('X-API-Key', app.apiKey)
|
|
try {
|
|
const res = await fetch(upstream, { method: request.method, headers, body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined })
|
|
const contentType = res.headers.get('content-type') ?? 'application/json'
|
|
set.status = res.status
|
|
return new Response(res.body, { status: res.status, headers: { 'content-type': contentType } })
|
|
} catch (e) {
|
|
set.status = 502
|
|
return { error: 'Gagal menghubungi API desa-plus', detail: String(e) }
|
|
}
|
|
}, {
|
|
detail: { summary: 'Proxy Desa Plus API', tags: ['Proxy'] },
|
|
})
|
|
}
|