feat: runtime config via DB — ganti VITE_URL_API_DESA_PLUS dengan proxy

- Tambah model AppConfig (key-value) ke schema + migration
- Tambah GET/PUT /api/admin/config (DEVELOPER only)
- Tambah proxy /api/proxy/desa-plus/* yang baca URL dari DB
- Hapus VITE_URL_API_DESA_PLUS dari frontend, ganti semua URL desa-plus ke relative proxy path
- Aktifkan Settings tab di /dev dengan UI untuk set URL_API_DESA_PLUS

URL desa-plus kini bisa diubah via /dev → Settings tanpa rebuild image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 15:47:21 +08:00
parent b63117694b
commit ccc43e0c96
6 changed files with 197 additions and 31 deletions

View File

@@ -1552,4 +1552,54 @@ export function createApp() {
}
return { sessions: result, summary: { totalSessions: result.length, activeSessions: active, expiredSessions: expired, onlineUsers: onlineIds.size, byRole } }
})
// ─── App Config ────────────────────────────────────────────────────────────
.get('/api/admin/config', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const configs = await prisma.appConfig.findMany({ orderBy: { key: 'asc' } })
return { configs: configs.map((c) => ({ key: c.key, value: c.value, updatedAt: c.updatedAt.toISOString() })) }
}, {
detail: { summary: 'Get App Config', tags: ['Admin'] },
})
.put('/api/admin/config', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const body = await request.json() as { key: string; value: string }
if (!body.key || typeof body.value !== 'string') { set.status = 400; return { error: 'key and value required' } }
const config = await prisma.appConfig.upsert({
where: { key: body.key },
update: { value: body.value },
create: { key: body.key, value: body.value },
})
await createSystemLog(auth.userId, 'UPDATE', `Updated app config: ${body.key}`)
return { key: config.key, value: config.value, updatedAt: config.updatedAt.toISOString() }
}, {
detail: { summary: 'Update App Config', tags: ['Admin'] },
})
// ─── Desa Plus Proxy ───────────────────────────────────────────────────────
.all('/api/proxy/desa-plus/*', async ({ request, set }) => {
const baseConfig = await prisma.appConfig.findUnique({ where: { key: 'URL_API_DESA_PLUS' } })
if (!baseConfig?.value) { set.status = 503; return { error: 'URL_API_DESA_PLUS belum dikonfigurasi. Set di /dev → Settings.' } }
const base = baseConfig.value.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')
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'] },
})
}