diff --git a/prisma/migrations/20260415014750_tambah_table_app_and_enum_role/migration.sql b/prisma/migrations/20260415014750_tambah_table_app_and_enum_role/migration.sql
new file mode 100644
index 0000000..6a14dba
--- /dev/null
+++ b/prisma/migrations/20260415014750_tambah_table_app_and_enum_role/migration.sql
@@ -0,0 +1,40 @@
+/*
+ Warnings:
+
+ - The values [USER,SUPER_ADMIN] on the enum `Role` will be removed. If these variants are still used in the database, this will fail.
+ - You are about to drop the column `app` on the `bug` table. All the data in the column will be lost.
+
+*/
+-- AlterEnum
+BEGIN;
+CREATE TYPE "Role_new" AS ENUM ('ADMIN', 'DEVELOPER');
+ALTER TABLE "public"."user" ALTER COLUMN "role" DROP DEFAULT;
+ALTER TABLE "user" ALTER COLUMN "role" TYPE "Role_new" USING ("role"::text::"Role_new");
+ALTER TYPE "Role" RENAME TO "Role_old";
+ALTER TYPE "Role_new" RENAME TO "Role";
+DROP TYPE "public"."Role_old";
+ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'ADMIN';
+COMMIT;
+
+-- AlterTable
+ALTER TABLE "bug" DROP COLUMN "app",
+ADD COLUMN "appId" TEXT;
+
+-- AlterTable
+ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'ADMIN';
+
+-- CreateTable
+CREATE TABLE "App" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "version" TEXT NOT NULL,
+ "minVersion" TEXT NOT NULL,
+ "maintenance" BOOLEAN NOT NULL DEFAULT false,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "App_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "bug" ADD CONSTRAINT "bug_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260415024317_update_enum_app/migration.sql b/prisma/migrations/20260415024317_update_enum_app/migration.sql
new file mode 100644
index 0000000..9ce2368
--- /dev/null
+++ b/prisma/migrations/20260415024317_update_enum_app/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "App" ALTER COLUMN "version" DROP NOT NULL,
+ALTER COLUMN "minVersion" DROP NOT NULL;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 64b5f82..5667144 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -9,9 +9,7 @@ datasource db {
}
enum Role {
- USER
ADMIN
- SUPER_ADMIN
DEVELOPER
}
@@ -44,7 +42,7 @@ model User {
name String
email String @unique
password String
- role Role @default(USER)
+ role Role @default(ADMIN)
active Boolean @default(true)
image String?
createdAt DateTime @default(now())
@@ -71,6 +69,19 @@ model Session {
@@map("session")
}
+model App {
+ id String @id @default(uuid())
+ name String
+ version String?
+ minVersion String?
+ maintenance Boolean @default(false)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ bugs Bug[]
+
+}
+
model Log {
id String @id @default(uuid())
userId String
@@ -86,12 +97,12 @@ model Log {
model Bug {
id String @id @default(uuid())
userId String?
- app String?
+ appId String?
affectedVersion String
device String
os String
status BugStatus
- source BugSource
+ source BugSource
description String
stackTrace String?
fixedVersion String?
@@ -100,6 +111,7 @@ model Bug {
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id])
+ app App? @relation(fields: [appId], references: [id])
images BugImage[]
logs BugLog[]
diff --git a/prisma/seed.ts b/prisma/seed.ts
index f90de89..31e64df 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -6,9 +6,7 @@ const SUPER_ADMIN_EMAILS = (process.env.SUPER_ADMIN_EMAIL ?? '').split(',').map(
async function main() {
const users = [
- { name: 'Super Admin', email: 'superadmin@example.com', password: 'superadmin123', role: 'SUPER_ADMIN' as const },
{ name: 'Admin', email: 'admin@example.com', password: 'admin123', role: 'ADMIN' as const },
- { name: 'User', email: 'user@example.com', password: 'user123', role: 'USER' as const },
]
for (const u of users) {
@@ -21,13 +19,28 @@ async function main() {
console.log(`Seeded: ${u.email} (${u.role})`)
}
- // Promote super admin emails from env
+ // Promote DEVELOPER emails from env
for (const email of SUPER_ADMIN_EMAILS) {
- const user = await prisma.user.findUnique({ where: { email } })
- if (user && user.role !== 'SUPER_ADMIN') {
- await prisma.user.update({ where: { email }, data: { role: 'SUPER_ADMIN' } })
- console.log(`Promoted to SUPER_ADMIN: ${email}`)
- }
+ const password = await Bun.password.hash('developer123', { algorithm: 'bcrypt' })
+ await prisma.user.upsert({
+ where: { email },
+ update: { role: 'DEVELOPER', password },
+ create: { name: email.split('@')[0].toUpperCase(), email, password, role: 'DEVELOPER' },
+ })
+ console.log(`Promoted to DEVELOPER: ${email}`)
+ }
+
+ const apps = [
+ { id: 'desa-plus', name: 'Desa+' },
+ ]
+
+ for (const a of apps) {
+ await prisma.app.upsert({
+ where: { id: a.id },
+ update: { name: a.name },
+ create: { id: a.id, name: a.name },
+ })
+ console.log(`Seeded: ${a.name}`)
}
}
diff --git a/src/app.ts b/src/app.ts
index b1a38ef..f860ae5 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -37,8 +37,8 @@ export function createApp() {
return { error: 'Email atau password salah' }
}
// Auto-promote super admin from env
- if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'SUPER_ADMIN') {
- user = await prisma.user.update({ where: { id: user.id }, data: { role: 'SUPER_ADMIN' } })
+ 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
@@ -78,80 +78,7 @@ export function createApp() {
return { user: session.user }
})
- // ─── Google OAuth ──────────────────────────────────
- .get('/api/auth/google', ({ request, set }) => {
- const origin = new URL(request.url).origin
- const params = new URLSearchParams({
- client_id: env.GOOGLE_CLIENT_ID,
- redirect_uri: `${origin}/api/auth/callback/google`,
- response_type: 'code',
- scope: 'openid email profile',
- access_type: 'offline',
- prompt: 'consent',
- })
- set.status = 302; set.headers['location'] = `https://accounts.google.com/o/oauth2/v2/auth?${params}`
- })
- .get('/api/auth/callback/google', async ({ request, set }) => {
- const url = new URL(request.url)
- const code = url.searchParams.get('code')
- const origin = url.origin
-
- if (!code) {
- set.status = 302; set.headers['location'] = '/login?error=google_failed'
- return
- }
-
- // Exchange code for tokens
- 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) {
- set.status = 302; set.headers['location'] = '/login?error=google_failed'
- return
- }
-
- const tokens = (await tokenRes.json()) as { access_token: string }
-
- // Get user info
- const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
- headers: { Authorization: `Bearer ${tokens.access_token}` },
- })
-
- if (!userInfoRes.ok) {
- set.status = 302; set.headers['location'] = '/login?error=google_failed'
- return
- }
-
- const googleUser = (await userInfoRes.json()) as { email: string; name: string }
-
- // Upsert user (no password for Google users)
- const isSuperAdmin = env.SUPER_ADMIN_EMAILS.includes(googleUser.email)
- const user = await prisma.user.upsert({
- where: { email: googleUser.email },
- update: { name: googleUser.name, ...(isSuperAdmin ? { role: 'SUPER_ADMIN' } : {}) },
- create: { email: googleUser.email, name: googleUser.name, password: '', role: isSuperAdmin ? 'SUPER_ADMIN' : 'USER' },
- })
-
- // Create session
- 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 via Google')
-
- set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
- set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
- })
// ─── Monitoring API ────────────────────────────────
.get('/api/dashboard/stats', async () => {
@@ -172,7 +99,7 @@ export function createApp() {
})
return bugs.map(b => ({
id: b.id,
- app: b.app,
+ app: b.appId,
message: b.description,
version: b.affectedVersion,
time: b.createdAt.toISOString(),
@@ -180,18 +107,56 @@ export function createApp() {
}))
})
- .get('/api/apps', async () => {
- const desaPlusErrors = await prisma.bug.count({ where: { app: { in: ['desa-plus', 'desa_plus'] }, status: 'OPEN' } })
- return [
- { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: desaPlusErrors, version: '2.4.1' },
- ]
+ .get('/api/apps', async ({ query }) => {
+ const search = (query.search as string) || ''
+ 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,
+ }))
})
- .get('/api/apps/:appId', ({ params: { appId } }) => {
- const apps = {
- 'desa-plus': { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
+ .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,
}
- return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' }
})
.get('/api/logs', async ({ query }) => {
@@ -246,7 +211,7 @@ export function createApp() {
}
const body = (await request.json()) as { type: string, message: string }
- const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }))?.id || ''
+ const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }))?.id || ''
await createSystemLog(actingUserId, body.type as any, body.message)
return { ok: true }
@@ -419,7 +384,7 @@ export function createApp() {
]
}
if (app && app !== 'all') {
- where.app = app
+ where.appId = app
}
if (status && status !== 'all') {
where.status = status
@@ -463,12 +428,12 @@ export function createApp() {
}
const body = (await request.json()) as any
- const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } })
+ const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
const actingUserId = userId || defaultAdmin?.id || ''
const bug = await prisma.bug.create({
data: {
- app: body.app,
+ appId: body.app,
affectedVersion: body.affectedVersion,
device: body.device,
os: body.os,
@@ -508,7 +473,7 @@ export function createApp() {
}
const body = (await request.json()) as { feedBack: string }
- const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } })
+ const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
const actingUserId = userId || defaultAdmin?.id || undefined
const bug = await prisma.bug.update({
@@ -538,7 +503,7 @@ export function createApp() {
}
const body = (await request.json()) as { status: string; description?: string }
- const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } })
+ const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
const actingUserId = userId || defaultAdmin?.id || undefined
const bug = await prisma.bug.update({
@@ -562,6 +527,30 @@ export function createApp() {
return bug
})
+ // ─── 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(),
+ }
+ }
+ })
+
// ─── Example API ───────────────────────────────────
.get('/api/hello', () => ({
message: 'Hello, world!',
diff --git a/src/frontend/components/AppCard.tsx b/src/frontend/components/AppCard.tsx
index 3a8d485..7c5fcff 100644
--- a/src/frontend/components/AppCard.tsx
+++ b/src/frontend/components/AppCard.tsx
@@ -1,17 +1,18 @@
-import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box, useComputedColorScheme } from '@mantine/core'
+import { Avatar, Button, Card, Group, Stack, Text, useComputedColorScheme } from '@mantine/core'
import { Link } from '@tanstack/react-router'
-import { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb'
+import { TbChevronRight, TbDeviceMobile } from 'react-icons/tb'
interface AppCardProps {
id: string
name: string
status: 'active' | 'warning' | 'error'
- users: number
+ users?: number
errors: number
version: string
+ maintenance?: boolean
}
-export function AppCard({ id, name, status, users, errors, version }: AppCardProps) {
+export function AppCard({ id, name, status, errors, version }: AppCardProps) {
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
@@ -46,12 +47,12 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro