diff --git a/prisma/migrations/20260413034305_add/migration.sql b/prisma/migrations/20260413034305_add/migration.sql new file mode 100644 index 0000000..d61c9e9 --- /dev/null +++ b/prisma/migrations/20260413034305_add/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Changed the type of `type` on the `log` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- CreateEnum +CREATE TYPE "LogType" AS ENUM ('CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT'); + +-- AlterTable +ALTER TABLE "log" DROP COLUMN "type", +ADD COLUMN "type" "LogType" NOT NULL; diff --git a/prisma/migrations/20260413044057_add/migration.sql b/prisma/migrations/20260413044057_add/migration.sql new file mode 100644 index 0000000..bfa3665 --- /dev/null +++ b/prisma/migrations/20260413044057_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "image" TEXT; diff --git a/prisma/migrations/20260413071605_add/migration.sql b/prisma/migrations/20260413071605_add/migration.sql new file mode 100644 index 0000000..1affce6 --- /dev/null +++ b/prisma/migrations/20260413071605_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "bug_log" ALTER COLUMN "userId" DROP NOT NULL; diff --git a/prisma/migrations/20260413085051_update_type_data/migration.sql b/prisma/migrations/20260413085051_update_type_data/migration.sql new file mode 100644 index 0000000..6253d66 --- /dev/null +++ b/prisma/migrations/20260413085051_update_type_data/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - The `app` column on the `bug` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- AlterTable +ALTER TABLE "bug" DROP COLUMN "app", +ADD COLUMN "app" TEXT; + +-- DropEnum +DROP TYPE "App"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index baa9904..64b5f82 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,10 +15,6 @@ enum Role { DEVELOPER } -enum App{ - desa_plus - hipmi -} enum BugSource{ QC @@ -35,6 +31,14 @@ enum BugStatus{ CLOSED } +enum LogType{ + CREATE + UPDATE + DELETE + LOGIN + LOGOUT +} + model User { id String @id @default(uuid()) name String @@ -42,6 +46,7 @@ model User { password String role Role @default(USER) active Boolean @default(true) + image String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -69,7 +74,7 @@ model Session { model Log { id String @id @default(uuid()) userId String - type String + type LogType message String createdAt DateTime @default(now()) @@ -81,7 +86,7 @@ model Log { model Bug { id String @id @default(uuid()) userId String? - app App + app String? affectedVersion String device String os String @@ -116,13 +121,13 @@ model BugImage { model BugLog { id String @id @default(uuid()) bugId String - userId String + userId String? status BugStatus description String createdAt DateTime @default(now()) bug Bug @relation(fields: [bugId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("bug_log") } diff --git a/src/app.ts b/src/app.ts index d5b568e..f4f93e4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,6 +3,7 @@ import { html } from '@elysiajs/html' import { Elysia } from 'elysia' import { prisma } from './lib/db' import { env } from './lib/env' +import { createSystemLog } from './lib/logger' export function createApp() { return new Elysia() @@ -43,13 +44,20 @@ export function createApp() { const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` + await createSystemLog(user.id, 'LOGIN', 'Logged in successfully') return { user: { id: user.id, name: user.name, email: user.email, role: user.role } } }) .post('/api/auth/logout', async ({ request, set }) => { const cookie = request.headers.get('cookie') ?? '' const token = cookie.match(/session=([^;]+)/)?.[1] - if (token) await prisma.session.deleteMany({ where: { token } }) + if (token) { + const sessionObj = await prisma.session.findUnique({ where: { token } }) + if (sessionObj) { + await createSystemLog(sessionObj.userId, 'LOGOUT', 'Logged out successfully') + await prisma.session.deleteMany({ where: { token } }) + } + } set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0' return { ok: true } }) @@ -139,6 +147,8 @@ export function createApp() { 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' }) @@ -153,8 +163,8 @@ export function createApp() { .get('/api/apps', () => [ { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' }, - { id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' }, - { id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' }, + // { id: 'e-commerce', name: 'E-Commerce', status: 'warning', users: 8900, errors: 45, version: '1.8.0' }, + // { id: 'fitness-app', name: 'Fitness App', status: 'error', users: 3200, errors: 128, version: '0.9.5' }, ]) .get('/api/apps/:appId', ({ params: { appId } }) => { @@ -164,6 +174,214 @@ export function createApp() { 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 }) => { + const page = Number(query.page) || 1 + const limit = Number(query.limit) || 20 + const search = (query.search as string) || '' + const type = query.type as any + const userId = query.userId as string + + const where: any = {} + if (search) { + where.OR = [ + { message: { contains: search, mode: 'insensitive' } }, + { user: { name: { contains: search, mode: 'insensitive' } } } + ] + } + if (type && type !== 'all') { + where.type = type + } + if (userId && userId !== 'all') { + where.userId = userId + } + + const [logs, total] = await Promise.all([ + prisma.log.findMany({ + where, + include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.log.count({ where }) + ]) + + return { + data: logs, + totalPages: Math.ceil(total / limit), + totalItems: total + } + }) + + .post('/api/logs', async ({ 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 body = (await request.json()) as { type: string, message: string } + const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }))?.id || '' + + await createSystemLog(actingUserId, body.type as any, body.message) + return { ok: true } + }) + + .get('/api/operators', async ({ query }) => { + const page = Number(query.page) || 1 + const limit = Number(query.limit) || 20 + const search = (query.search as string) || '' + + 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 + } + }) + + .get('/api/operators/stats', async () => { + const [totalStaff, activeNow, rolesGroup] = await Promise.all([ + prisma.user.count(), + prisma.session.count({ + where: { expiresAt: { gte: new Date() } }, + }), + prisma.user.groupBy({ + by: ['role'], + _count: true + }) + ]) + + return { + totalStaff, + activeNow, + rolesCount: rolesGroup.length + } + }) + + .get('/api/logs/operators', async () => { + return await prisma.user.findMany({ + select: { id: true, name: true, image: true }, + orderBy: { name: 'asc' } + }) + }) + + .get('/api/bugs', async ({ query }) => { + const page = Number(query.page) || 1 + const limit = Number(query.limit) || 20 + const search = (query.search as string) || '' + const app = query.app as any + const status = query.status as any + + const where: any = {} + if (search) { + where.OR = [ + { description: { contains: search, mode: 'insensitive' } }, + { device: { contains: search, mode: 'insensitive' } }, + { os: { contains: search, mode: 'insensitive' } }, + { affectedVersion: { contains: search, mode: 'insensitive' } }, + ] + } + if (app && app !== 'all') { + where.app = app + } + if (status && status !== 'all') { + where.status = status + } + + const [bugs, total] = await Promise.all([ + prisma.bug.findMany({ + where, + include: { + user: { select: { id: true, name: true, email: true, image: true } }, + images: true, + logs: { + include: { user: { select: { id: true, name: true, image: true } } }, + orderBy: { createdAt: 'desc' }, + }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.bug.count({ where }), + ]) + + return { + data: bugs, + totalPages: Math.ceil(total / limit), + totalItems: total, + } + }) + + .post('/api/bugs', async ({ 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 body = (await request.json()) as any + const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }) + const actingUserId = userId || defaultAdmin?.id || '' + + const bug = await prisma.bug.create({ + data: { + app: body.app, + affectedVersion: body.affectedVersion, + device: body.device, + os: body.os, + status: body.status || 'OPEN', + source: body.source || 'USER', + description: body.description, + stackTrace: body.stackTrace, + userId: userId, + images: body.imageUrl ? { + create: { + imageUrl: body.imageUrl + } + } : undefined, + logs: { + create: { + userId: actingUserId, + status: body.status || 'OPEN', + description: 'Bug reported initially.', + }, + }, + }, + }) + + return bug + }) + // ─── Example API ─────────────────────────────────── .get('/api/hello', () => ({ message: 'Hello, world!', diff --git a/src/frontend/components/DashboardLayout.tsx b/src/frontend/components/DashboardLayout.tsx index 1f59101..0cc88c4 100644 --- a/src/frontend/components/DashboardLayout.tsx +++ b/src/frontend/components/DashboardLayout.tsx @@ -29,7 +29,8 @@ import { TbSun, TbMoon, TbUser, - TbHistory + TbHistory, + TbBug } from 'react-icons/tb' interface DashboardLayoutProps { @@ -52,6 +53,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { { label: 'Dashboard', icon: TbDashboard, to: '/dashboard' }, { label: 'Applications', icon: TbApps, to: '/apps' }, { label: 'Log Activity', icon: TbHistory, to: '/logs' }, + { label: 'Bug Reports', icon: TbBug, to: '/bug-reports' }, { label: 'Users', icon: TbUser, to: '/users' }, ] diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index 5489f44..9472c17 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -23,4 +23,16 @@ export const API_URLS = { listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`, listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`, editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`, + updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`, + editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`, + getGlobalLogs: (page: number, search: string, type: string, userId: string) => + `/api/logs?page=${page}&search=${encodeURIComponent(search)}&type=${type}&userId=${userId}`, + getLogOperators: () => `/api/logs/operators`, + getOperators: (page: number, search: string) => + `/api/operators?page=${page}&search=${encodeURIComponent(search)}`, + getOperatorStats: () => `/api/operators/stats`, + getBugs: (page: number, search: string, app: string, status: string) => + `/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`, + createBug: () => `/api/bugs`, + createLog: () => `/api/logs`, } diff --git a/src/frontend/routes/apps.$appId.index.tsx b/src/frontend/routes/apps.$appId.index.tsx index acb394f..89f6603 100644 --- a/src/frontend/routes/apps.$appId.index.tsx +++ b/src/frontend/routes/apps.$appId.index.tsx @@ -1,33 +1,30 @@ -import { useEffect, useState } from 'react' -import { notifications } from '@mantine/notifications' import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts' import { ErrorDataTable } from '@/frontend/components/ErrorDataTable' import { SummaryCard } from '@/frontend/components/SummaryCard' import { - ActionIcon, + Badge, + Button, Group, + Modal, SimpleGrid, Stack, - Text, - Title, - Modal, - Button, - TextInput, Switch, - Badge, + Text, Textarea, - Skeleton + TextInput, + Title } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router' -import useSWR from 'swr' +import { notifications } from '@mantine/notifications' +import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' +import { useEffect, useState } from 'react' import { TbActivity, TbAlertTriangle, TbBuildingCommunity, - TbRefresh, TbVersions } from 'react-icons/tb' +import useSWR from 'swr' import { API_URLS } from '../config/api' export const Route = createFileRoute('/apps/$appId/')({ @@ -89,6 +86,12 @@ function AppOverviewPage() { }) if (response.ok) { + await fetch(API_URLS.createLog(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'UPDATE', message: `Update version information: ${JSON.stringify({ latestVersion, minVersion, maintenance, messageUpdate })}` }) + }).catch(console.error) + notifications.show({ title: 'Update Successful', message: 'Application version information has been updated.', @@ -118,29 +121,29 @@ function AppOverviewPage() { <> - setLatestVersion(e.currentTarget.value)} /> - setMinVersion(e.currentTarget.value)} /> -