import formatDateTime from "@/lib/formatDateTime"; import timeAgo from "@/lib/timeAgo"; import { prisma } from "@/module/_global"; import cors from "@elysiajs/cors"; import { swagger } from "@elysiajs/swagger"; import Elysia, { t } from "elysia"; import _ from "lodash"; import moment from "moment"; import "moment/locale/id"; // Gabungkan semua ke dalam satu instance server yang dipasang di /api/monitoring const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) .use(cors({ origin: "*", methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization"] })) .use(swagger({ path: "/docs", // Karena prefix instance adalah /api/monitoring, maka ini akan diakses di /api/monitoring/docs documentation: { info: { title: "Des Plus - Monitoring API", version: "1.0.0", description: "API Khusus untuk kebutuhan Dashboard Monitoring", } } })) .onBeforeHandle(({ request, set, path }) => { // Docs tidak perlu API key if (path.startsWith("/api/monitoring/docs")) return; const apiKey = process.env.MONITORING_API_KEY; const incoming = request.headers.get("x-api-key"); if (!apiKey || incoming !== apiKey) { set.status = 401; return { success: false, message: "Unauthorized" }; } }) .get("/grid-overview", async ({ query, set }) => { try { const version = await prisma.setting.findMany({ select: { id: true, name: true, value: true } }); const result_version = Object.fromEntries(version.map(item => [item.id, item.value])); const activity_today = await prisma.userLog.count({ where: { createdAt: { gte: moment().subtract(1, 'days').toDate(), lte: moment().toDate(), } } }) const activity_yesterday = await prisma.userLog.count({ where: { createdAt: { gte: moment().subtract(2, 'days').toDate(), lte: moment().subtract(1, 'days').toDate(), } } }) const activity_increase = (activity_today - activity_yesterday); const percentage_increase = (activity_increase / activity_yesterday) * 100 const total_village = await prisma.village.findMany({ where: { isDummy: false } }) const total_village_active = total_village.filter((item) => item.isActive).length const total_village_inactive = total_village.filter((item) => !item.isActive).length return { success: true, message: "Berhasil mendapatkan data", data: { version: result_version, activity: { today: activity_today, increase: _.isNaN(percentage_increase) ? 0 : percentage_increase.toFixed(2), }, village: { active: total_village_active, inactive: total_village_inactive, }, }, }; } catch (error) { console.error("[overview] grid-overview error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { detail: { summary: "Grid Overview", description: "Menu Overview - Mendapatkan daftar versi aplikasi.", tags: ["overview"], }, } ) .get("/daily-activity", async ({ query, set }) => { try { const VALID_RANGES = [7, 30, 90]; const range = VALID_RANGES.includes(Number(query.range)) ? Number(query.range) : 7; const data = await prisma.$queryRaw` SELECT DATE(ul."createdAt") AS tanggal, COUNT(*) AS total FROM "UserLog" ul JOIN "User" u ON ul."idUser" = u."id" JOIN "Village" v ON u."idVillage" = v."id" WHERE v."isDummy" = false AND ul."createdAt" >= NOW() - (${range} * INTERVAL '1 day') GROUP BY tanggal ORDER BY tanggal;` as any[]; const result = []; const map = data.reduce((acc: any, item: any) => { const key = moment(item.tanggal).format('YYYY-MM-DD'); acc[key] = Number(item.total); return acc; }, {}); for (let i = range - 1; i >= 0; i--) { const date = moment().subtract(i, 'days'); const key = date.format('YYYY-MM-DD'); result.push({ date: date.format('DD MMM'), logs: map[key] || 0 }); } return { success: true, message: "Berhasil mendapatkan data", data: result, }; } catch (error) { console.error("[overview] daily-activity error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ range: t.Optional(t.String({ description: "Rentang hari: 7, 30, atau 90 (default: 7)" })), }), detail: { summary: "Daily Activity", description: "Menu Overview - Mendapatkan data grafik aktivitas harian semua desa. Gunakan ?range=30 atau ?range=90 untuk rentang lebih panjang.", tags: ["overview"], }, } ) .get("/comparison-activity", async ({ query, set }) => { try { const VALID_RANGES = [7, 30, 90]; const range = VALID_RANGES.includes(Number(query.range)) ? Number(query.range) : 7; const data = await prisma.$queryRaw` SELECT v."name", COUNT(ul."id") AS total_logs FROM "UserLog" ul JOIN "User" u ON ul."idUser" = u."id" JOIN "Village" v ON u."idVillage" = v."id" WHERE v."isDummy" = false AND ul."createdAt" >= NOW() - (${range} * INTERVAL '1 day') GROUP BY v."id", v."name" ORDER BY total_logs DESC; ` as any[]; const result = data.map((item: any) => ({ village: item.name, activity: Number(item.total_logs), })); return { success: true, message: "Berhasil mendapatkan data", data: result, }; } catch (error) { console.error("[overview] comparison-activity error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ range: t.Optional(t.String({ description: "Rentang hari: 7, 30, atau 90 (default: 7)" })), }), detail: { summary: "Comparison Activity", description: "Menu Overview - Mendapatkan data grafik perbandingan aktivitas desa. Gunakan ?range=30 atau ?range=90 untuk rentang lebih panjang.", tags: ["overview"], }, } ) .post("/version-update", async ({ body, set }) => { try { const { mobile_latest_version, mobile_minimum_version, mobile_maintenance, mobile_message_update } = body await prisma.$transaction([ prisma.setting.update({ where: { id: "mobile_latest_version" }, data: { value: mobile_latest_version }, }), prisma.setting.update({ where: { id: "mobile_minimum_version" }, data: { value: mobile_minimum_version }, }), prisma.setting.update({ where: { id: "mobile_maintenance" }, data: { value: mobile_maintenance.toString() }, }), prisma.setting.update({ where: { id: "mobile_message_update" }, data: { value: mobile_message_update }, }), ]); return { success: true, message: "Berhasil update data", }; } catch (error) { console.error("[overview] version-update error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", }; } }, { body: t.Object({ mobile_latest_version: t.String({ error: "mobile latest version harus diisi", description: "mobile latest version yang ingin diupdate" }), mobile_minimum_version: t.String({ error: "mobile minimum version harus diisi", description: "mobile minimum version yang ingin diupdate" }), mobile_maintenance: t.Boolean({ description: "status maintenance mobile app" }), mobile_message_update: t.String({ description: "pesan update mobile app" }), }), detail: { summary: "Version Update", description: "Menu Overview - Mengupdate data versi aplikasi.", tags: ["overview"], }, } ) .get("/get-villages", async ({ query, set }) => { const { search, page } = query; const pageNum = Number(page ?? 1); try { const data = await prisma.village.findMany({ where: { ...(search && { name: { contains: search, mode: 'insensitive' } }) }, select: { id: true, name: true, isActive: true, isDummy: true, createdAt: true, User: { where: { idUserRole: "supadmin" }, select: { name: true, }, take: 1, }, }, skip: (pageNum - 1) * 10, take: 10, }) const count = await prisma.village.count({ where: { ...(search && { name: { contains: search, mode: 'insensitive' } }) }, }) const result = data.map((village) => ({ id: village.id, name: village.name, isActive: village.isActive, isDummy: village.isDummy, createdAt: formatDateTime(village.createdAt), perbekel: village.User[0]?.name || null, })); return { success: true, message: "Berhasil mendapatkan data", data: result, totalPage: Math.ceil(count / 10), currentPage: pageNum, totalData: count, }; } catch (error) { console.error("[villages] get-villages error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ search: t.Optional(t.String({ description: "Kata kunci pencarian nama desa" })), page: t.Optional(t.String({ description: "Halaman data (default: 1)" })), }), detail: { summary: "Get Villages", description: "Menu Villages - Mendapatkan semua data desa.", tags: ["villages"], }, } ) .post("/create-villages", async ({ body, set }) => { const { name, desc, username, phone, nik, email, gender } = body; try { const create_village = await prisma.village.create({ data: { name: name, desc: desc, isDummy: false, }, select: { id: true } }) if (create_village) { const create_group = await prisma.group.create({ data: { idVillage: create_village.id, name: "Dinas", }, select: { id: true } }) const create_position = await prisma.position.create({ data: { idGroup: create_group.id, name: "Perbekel", }, select: { id: true } }) const cek_user = await prisma.user.count({ where: { OR: [ { nik: nik }, { phone: phone }, { email: email }, ] }, }); if (cek_user > 0) { return { success: true, message: "Desa berhasil ditambahkan, namun user sudah terdaftar. Silahkan daftar user pada menu list user.", }; } const create_user = await prisma.user.create({ data: { idUserRole: "supadmin", idVillage: create_village.id, idGroup: create_group.id, idPosition: create_position.id, nik: nik, name: username, phone: phone, email: email, gender: gender }, select: { id: true } }) if (create_user) { return { success: true, message: "Desa dan user berhasil ditambahkan.", }; } else { return { success: true, message: "Desa berhasil ditambahkan, namun user gagal ditambahkan. Silahkan daftar user pada menu list user.", }; } } else { return { success: false, message: "Gagal menambahkan data", }; } } catch (error) { console.error("[villages] create-villages error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", }; } }, { body: t.Object({ name: t.String({ description: "Nama desa" }), desc: t.String({ description: "Deskripsi desa" }), username: t.String({ description: "Username" }), phone: t.String({ description: "Nomor telepon" }), nik: t.String({ description: "Nomor Induk Kependudukan" }), email: t.String({ description: "Email" }), gender: t.String({ description: "Jenis Kelamin" }), }), detail: { summary: "Create Villages", description: "Menu Villages - Membuat data desa.", tags: ["villages"], }, } ) .get("/info-villages", async ({ query, set }) => { const { id } = query; try { const data = await prisma.village.findUnique({ where: { id: id, }, select: { id: true, name: true, isActive: true, isDummy: true, createdAt: true, updatedAt: true, desc: true, User: { where: { idUserRole: "supadmin" }, select: { name: true, }, take: 1, }, }, }) if (!data) { set.status = 404; return { success: false, message: "Desa tidak ditemukan", data: null, }; } const result = data ? { id: data?.id, name: data?.name, isActive: data?.isActive, isDummy: data?.isDummy, desc: data?.desc, createdAt: data?.createdAt ? formatDateTime(data.createdAt) : null, updatedAt: data?.updatedAt ? formatDateTime(data.updatedAt) : null, perbekel: data?.User[0]?.name || null, } : null; return { success: true, message: "Berhasil mendapatkan data", data: result, }; } catch (error) { console.error("[detail-villages] info-villages error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ id: t.Optional(t.String({ description: "ID desa" })), }), detail: { summary: "Info Villages", description: "Menu Detail Villages - Mendapatkan info data desa untuk header dan kolom Informasi Sistem.", tags: ["detail-villages"], }, } ) .get("/grid-villages", async ({ query, set }) => { const { id } = query; try { const village = await prisma.village.findUnique({ where: { id: id } }); if (!village) { set.status = 404; return { success: false, message: "Desa tidak ditemukan", data: null, }; } const dataUser = await prisma.user.findMany({ where: { idVillage: id, NOT: { idUserRole: "developer" } } }) const dataGroup = await prisma.group.findMany({ where: { idVillage: id, } }) const dataDivision = await prisma.division.findMany({ where: { idVillage: id, } }) const dataProject = await prisma.project.findMany({ where: { idVillage: id } }) const result = { user: { active: dataUser.filter((user) => user.isActive).length, nonActive: dataUser.filter((user) => !user.isActive).length, }, group: { active: dataGroup.filter((group) => group.isActive).length, nonActive: dataGroup.filter((group) => !group.isActive).length, }, division: { active: dataDivision.filter((division) => division.isActive).length, nonActive: dataDivision.filter((division) => !division.isActive).length, }, project: { active: dataProject.filter((project) => project.isActive).length, nonActive: dataProject.filter((project) => !project.isActive).length, } }; return { success: true, message: "Berhasil mendapatkan data", data: result, }; } catch (error) { console.error("[detail-villages] grid-villages error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ id: t.Optional(t.String({ description: "ID desa" })), }), detail: { summary: "Grid Villages", description: "Menu Grid Villages - Mendapatkan info data desa untuk 4 grid untuk halaman detail desa.", tags: ["detail-villages"], }, } ) .get("/graph-log-villages", async ({ query, set }) => { const { id, time, dateFrom, dateTo } = query; try { const village = await prisma.village.findUnique({ where: { id }, }); if (!village) { set.status = 404; return { success: false, message: "Desa tidak ditemukan", data: null, }; } const now = new Date(); let startDate: Date; let endDate: Date = now; const useCustomRange = !!(dateFrom && dateTo); if (useCustomRange) { startDate = new Date(dateFrom); endDate = new Date(dateTo); endDate.setHours(23, 59, 59, 999); } else if (time === "daily") { startDate = new Date(); startDate.setDate(now.getDate() - 13); // 14 hari } else if (time === "monthly") { startDate = new Date(now.getFullYear(), 0, 1); // awal tahun } else if (time === "yearly") { startDate = new Date(now.getFullYear() - 4, 0, 1); // 5 tahun terakhir } else { startDate = new Date(0); } const dataLog = await prisma.userLog.findMany({ where: { createdAt: { gte: startDate, ...(useCustomRange ? { lte: endDate } : {}), }, User: { idVillage: id, }, }, select: { createdAt: true, }, }); // ========================= // 🔥 GROUPING // ========================= const map: Record = {}; // Tentukan format label berdasarkan range const effectiveTime = useCustomRange ? ( // > 60 hari pakai monthly, selain itu daily (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) > 60 ? 'monthly' : 'daily' ) : time; dataLog.forEach((log) => { const date = new Date(log.createdAt); let label = ""; if (effectiveTime === "daily") { label = date.toLocaleDateString("id-ID", { day: "2-digit", month: "short", }); } else if (effectiveTime === "monthly") { label = date.toLocaleDateString("id-ID", { month: "short", year: "numeric", }); } else if (effectiveTime === "yearly") { label = date.getFullYear().toString(); } map[label] = (map[label] || 0) + 1; }); // ========================= // 🔥 FORMAT FINAL // ========================= let result: any[] = []; if (effectiveTime === "daily") { const days = useCustomRange ? Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) : 14; for (let i = days - 1; i >= 0; i--) { const d = new Date(endDate); d.setDate(d.getDate() - i); const label = d.toLocaleDateString("id-ID", { day: "2-digit", month: "short", }); result.push({ label, aktivitas: map[label] || 0, }); } } else if (effectiveTime === "monthly") { const s = new Date(startDate); const e = new Date(endDate); const months: string[] = []; const cursor = new Date(s.getFullYear(), s.getMonth(), 1); while (cursor <= e) { months.push(cursor.toLocaleDateString("id-ID", { month: "short", year: "numeric", })); cursor.setMonth(cursor.getMonth() + 1); } result = months.map((label) => ({ label, aktivitas: map[label] || 0, })); } else if (effectiveTime === "yearly") { const years = Object.keys(map).map(Number); if (years.length === 0) { const currentYear = new Date().getFullYear(); result = [{ label: currentYear.toString(), aktivitas: 0 }]; } else { const minYear = Math.min(...years); const maxYear = Math.max(...years); for (let y = minYear; y <= maxYear; y++) { result.push({ label: y.toString(), aktivitas: map[y.toString()] || 0, }); } } } return { success: true, message: "Berhasil mendapatkan data", data: result, }; } catch (error) { console.error("[graph-log-villages] error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ id: t.String({ description: "ID desa" }), time: t.Optional(t.Enum( { daily: "daily", monthly: "monthly", yearly: "yearly" }, { description: "Rentang waktu (daily = 14 hari, monthly = 1 tahun, yearly = per tahun). Default: daily" }, )), dateFrom: t.Optional(t.String({ description: "Filter dari tanggal (YYYY-MM-DD). Mengabaikan time jika diisi bersama dateTo." })), dateTo: t.Optional(t.String({ description: "Filter sampai tanggal (YYYY-MM-DD). Mengabaikan time jika diisi bersama dateFrom." })), }), detail: { summary: "Graph Log Villages", description: "Mendapatkan data grafik log aktivitas desa berdasarkan rentang waktu (harian, bulanan, tahunan) atau custom date range.", tags: ["detail-villages"], }, } ) .get("/recent-village-logs", async ({ query, set }) => { const { id } = query; try { const village = await prisma.village.findUnique({ where: { id }, }); if (!village) { set.status = 404; return { success: false, message: "Desa tidak ditemukan", data: null, }; } const logs = await prisma.userLog.findMany({ where: { User: { idVillage: id }, }, select: { createdAt: true, action: true, desc: true, User: { select: { name: true } }, }, orderBy: { createdAt: 'desc' }, take: 10, }); const result = logs.map((log) => ({ timestamp: log.createdAt, userName: log.User.name, action: log.action, desc: log.desc, })); return { success: true, message: "Berhasil mendapatkan data", data: result, }; } catch (error) { console.error("[recent-village-logs] error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ id: t.String({ description: "ID desa" }), }), detail: { summary: "Recent Village Logs", description: "Mendapatkan 10 log aktivitas terbaru di desa tertentu.", tags: ["detail-villages"], }, } ) .get("/list-group-villages", async ({ query, set }) => { const { id } = query; try { const data = await prisma.group.findMany({ where: { idVillage: id, }, select: { id: true, name: true, } }) if (!data) { set.status = 404; return { success: false, message: "Desa tidak ditemukan", data: null, }; } return { success: true, message: "Berhasil mendapatkan data", data: data, }; } catch (error) { console.error("[detail-villages] list-group-villages error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ id: t.Optional(t.String({ description: "ID desa" })), }), detail: { summary: "List Group Villages", description: "Menu Detail Villages - Mendapatkan list group untuk dropdown.", tags: ["detail-villages"], }, } ) .get("/list-position-villages", async ({ query, set }) => { const { id } = query; try { const data = await prisma.position.findMany({ where: { idGroup: id, }, select: { id: true, name: true, } }) if (!data) { set.status = 404; return { success: false, message: "Posisi tidak ditemukan", data: null, }; } return { success: true, message: "Berhasil mendapatkan data", data: data, }; } catch (error) { console.error("[detail-villages] list-position-villages error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ id: t.Optional(t.String({ description: "ID group" })), }), detail: { summary: "List Position Villages", description: "Menu Detail Villages - Mendapatkan list jabatan untuk dropdown.", tags: ["detail-villages"], }, } ) .get("/list-userrole-villages", async ({ query, set }) => { try { const data = await prisma.userRole.findMany({ select: { id: true, name: true, } }) if (!data) { set.status = 404; return { success: false, message: "Role tidak ditemukan", data: null, }; } return { success: true, message: "Berhasil mendapatkan data", data: data, }; } catch (error) { console.error("[detail-villages] list-userrole-villages error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { detail: { summary: "List User Role", description: "Menu Detail Villages - Mendapatkan list role untuk dropdown.", tags: ["detail-villages"], }, } ) .post("/edit-villages", async ({ body, set }) => { const { id, name, desc, isDummy } = body; try { const village = await prisma.village.findUnique({ where: { id }, }); if (!village) { set.status = 404; return { success: false, message: "Desa tidak ditemukan", }; } const upd = await prisma.village.update({ where: { id }, data: { name, desc, isDummy, }, }); return { success: true, message: "Berhasil mengupdate data", }; } catch (error) { console.error("[edit-villages] error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", }; } }, { body: t.Object({ id: t.String({ description: "ID desa" }), name: t.String({ description: "Nama desa" }), desc: t.String({ description: "Deskripsi desa" }), isDummy: t.Boolean({ description: "Apakah desa dummy" }), }), detail: { summary: "Edit Villages", description: "Mengupdate data desa", tags: ["detail-villages"], }, } ) .post("/update-status-villages", async ({ body, set }) => { const { id, active } = body; try { const village = await prisma.village.findUnique({ where: { id }, }); if (!village) { set.status = 404; return { success: false, message: "Desa tidak ditemukan", }; } const upd = await prisma.village.update({ where: { id }, data: { isActive: active, }, }); return { success: true, message: "Berhasil mengupdate data", }; } catch (error) { console.error("[update-status-villages] error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", }; } }, { body: t.Object({ id: t.String({ description: "ID desa" }), active: t.Boolean({ description: "Status desa" }), }), detail: { summary: "Update Status Villages", description: "Mengupdate status desa", tags: ["detail-villages"], }, } ) .get("/log-all-villages", async ({ query, set }) => { const { page = 1, search, action, idVillage, dateFrom, dateTo } = query; const pageNum = Number(page) || 1; const take = 15; const skip = (pageNum - 1) * take; const whereClause = { ...(action && { action: action.toUpperCase() }), ...(idVillage && { User: { idVillage } }), ...(dateFrom || dateTo) && { createdAt: { ...(dateFrom && { gte: new Date(dateFrom) }), ...(dateTo && { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) }), }, }, ...(search && { OR: [ { User: { name: { contains: search, mode: "insensitive" as const } } }, { User: { Village: { name: { contains: search, mode: "insensitive" as const } } } }, ], }), }; try { const dataLog = await prisma.userLog.findMany({ where: whereClause, select: { id: true, createdAt: true, action: true, desc: true, User: { select: { name: true, Village: { select: { name: true, }, }, }, }, }, orderBy: { createdAt: "desc", }, skip, take, }); const total = await prisma.userLog.count({ where: whereClause }); const result = dataLog.map((item) => ({ id: item.id, createdAt: timeAgo(item.createdAt), action: item.action, desc: item.desc, username: item.User.name, village: item.User.Village.name, })); return { success: true, message: "Berhasil mendapatkan data", data: { log: result, total, totalPage: Math.ceil(total / take), currentPage: pageNum, }, }; } catch (error) { console.error("[log-villages] error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ page: t.Optional(t.String({ description: "Halaman" })), search: t.Optional(t.String({ description: "Pencarian nama user atau desa" })), action: t.Optional(t.String({ description: "Filter jenis aksi: LOGIN | LOGOUT | CREATE | UPDATE | DELETE" })), idVillage: t.Optional(t.String({ description: "Filter berdasarkan ID desa" })), dateFrom: t.Optional(t.String({ description: "Tanggal mulai (ISO 8601, e.g. 2026-05-01)" })), dateTo: t.Optional(t.String({ description: "Tanggal akhir (ISO 8601, e.g. 2026-05-31)" })), }), detail: { summary: "Log Villages", description: "Mendapatkan data log aktivitas desa dengan filter aksi, desa, rentang tanggal, pencarian, dan paginasi", tags: ["log-activity"], }, } ) .get("/user", async ({ query, set }) => { const { page = 1, search, isActive, idUserRole, idVillage, orderBy = 'createdAt', orderDir = 'desc' } = query; const pageNum = Number(page) || 1; const take = 15; const skip = (pageNum - 1) * take; const SORTABLE_FIELDS = ['name', 'email', 'isActive', 'idUserRole', 'createdAt'] as const; type SortableField = typeof SORTABLE_FIELDS[number]; const safeOrderBy: SortableField = SORTABLE_FIELDS.includes(orderBy as SortableField) ? (orderBy as SortableField) : 'createdAt'; const safeOrderDir = orderDir === 'asc' ? 'asc' : 'desc'; const whereClause = { ...(isActive !== undefined && { isActive: isActive === 'true' }), ...(idUserRole && { idUserRole }), ...(idVillage && { idVillage }), ...(search && { OR: [ { name: { contains: search, mode: "insensitive" as const } }, { phone: { contains: search, mode: "insensitive" as const } }, { email: { contains: search, mode: "insensitive" as const } }, { nik: { contains: search, mode: "insensitive" as const } }, { Village: { name: { contains: search, mode: "insensitive" as const } } }, { idUserRole: search }, ], }), }; try { const data = await prisma.user.findMany({ where: whereClause, select: { id: true, name: true, nik: true, phone: true, email: true, isWithoutOTP: true, isActive: true, isApprover: true, idUserRole: true, idVillage: true, idGroup: true, idPosition: true, gender: true, UserRole: { select: { name: true, }, }, Village: { select: { name: true, }, }, Group: { select: { name: true, }, }, Position: { select: { name: true, }, }, }, orderBy: { [safeOrderBy]: safeOrderDir, }, skip, take, }); const total = await prisma.user.count({ where: whereClause }); const result = data.map((item) => ({ id: item.id, name: item.name, nik: item.nik, phone: item.phone, email: item.email, gender: item.gender, isWithoutOTP: item.isWithoutOTP, isActive: item.isActive, isApprover: item.isApprover, role: item.UserRole?.name, village: item.Village?.name, group: item.Group?.name, position: item.Position?.name, idUserRole: item.idUserRole, idVillage: item.idVillage, idGroup: item.idGroup, idPosition: item.idPosition, })); return { success: true, message: "Berhasil mendapatkan data", data: { user: result, total, totalPage: Math.ceil(total / take), currentPage: pageNum, }, }; } catch (error) { console.error("[user] error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", data: null, }; } }, { query: t.Object({ page: t.Optional(t.String({ description: "Halaman" })), search: t.Optional(t.String({ description: "Pencarian nama/NIK/email/telepon" })), isActive: t.Optional(t.String({ description: "Filter status: 'true' atau 'false'" })), idUserRole: t.Optional(t.String({ description: "Filter berdasarkan ID role" })), idVillage: t.Optional(t.String({ description: "Filter berdasarkan ID desa" })), orderBy: t.Optional(t.String({ description: "Kolom urutan: name | email | isActive | idUserRole | createdAt (default: createdAt)" })), orderDir: t.Optional(t.String({ description: "Arah urutan: asc | desc (default: desc)" })), }), detail: { summary: "User", description: "Mendapatkan data user dengan filter status, role, desa, pencarian, dan pengurutan", tags: ["user"], }, } ) .post("/create-user", async ({ body, set }) => { const { name, nik, phone, email, gender, idUserRole, idVillage, idGroup, idPosition } = body; try { const cekUser = await prisma.user.findFirst({ where: { OR: [ { nik }, { phone }, { email }, ], }, }); if (cekUser) { return { success: false, message: "User sudah ada", }; } const user = await prisma.user.create({ data: { name, nik, phone, email, gender, idUserRole, idVillage, idGroup, idPosition, }, }); return { success: true, message: "Berhasil membuat user", }; } catch (error) { console.error("[create-user] error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", }; } }, { body: t.Object({ name: t.String({ description: "Nama" }), nik: t.String({ description: "NIK" }), phone: t.String({ description: "Nomor Telepon" }), email: t.String({ description: "Email" }), gender: t.String({ description: "Jenis Kelamin" }), idUserRole: t.String({ description: "ID Role" }), idVillage: t.String({ description: "ID Desa" }), idGroup: t.String({ description: "ID Group" }), idPosition: t.Optional(t.String({ description: "ID Posisi" })), }), detail: { summary: "Create User", description: "Membuat user", tags: ["user"], }, } ) .post("/edit-user", async ({ body, set }) => { const { id, name, nik, phone, email, gender, idUserRole, idVillage, idGroup, idPosition, isActive, isWithoutOTP, isApprover } = body; try { const cekId = await prisma.user.findFirst({ where: { id, }, }); if (!cekId) { return { success: false, message: "User tidak ditemukan", }; } const cekUser = await prisma.user.findFirst({ where: { id: { not: id, }, OR: [ { nik }, { phone }, { email }, ], }, }); if (cekUser) { return { success: false, message: "User sudah ada", }; } const user = await prisma.user.update({ where: { id, }, data: { name, nik, phone, email, gender, idUserRole, idVillage, idGroup, idPosition, isActive, isWithoutOTP, isApprover, }, }); return { success: true, message: "Berhasil mengedit user", }; } catch (error) { console.error("[edit-user] error:", error); set.status = 500; return { success: false, message: "Terjadi kesalahan pada server", }; } }, { body: t.Object({ id: t.String({ description: "ID" }), name: t.String({ description: "Nama" }), nik: t.String({ description: "NIK" }), phone: t.String({ description: "Nomor Telepon" }), email: t.String({ description: "Email" }), gender: t.String({ description: "Jenis Kelamin" }), idUserRole: t.String({ description: "ID Role" }), idVillage: t.String({ description: "ID Desa" }), idGroup: t.String({ description: "ID Group" }), idPosition: t.Optional(t.Union([t.String(), t.Null()], { description: "ID Posisi" })), isActive: t.Boolean({ description: "Aktif" }), isWithoutOTP: t.Boolean({ description: "Tanpa OTP" }), isApprover: t.Boolean({ description: "Approver" }), }), detail: { summary: "Edit User", description: "Mengedit user", tags: ["user"], }, } ) // ─── API KEY MANAGEMENT ────────────────────────────────────────────────── .get("/api-keys", async ({ set }) => { try { const keys = await prisma.apiKey.findMany({ orderBy: { createdAt: "desc" } }); return { success: true, message: "Berhasil mendapatkan API keys", data: keys.map((k) => ({ ...k, key: k.key.slice(0, 8) + "••••••••" + k.key.slice(-4) })), }; } catch (error) { set.status = 500; return { success: false, message: "Gagal mendapatkan API keys" }; } }, { detail: { summary: "List API Keys", tags: ["api-key"] }, }) .get("/api-keys/:id", async ({ params, set }) => { try { const key = await prisma.apiKey.findUnique({ where: { id: params.id } }); if (!key) { set.status = 404; return { success: false, message: "API key tidak ditemukan" }; } return { success: true, data: key }; } catch (error) { set.status = 500; return { success: false, message: "Gagal mendapatkan API key" }; } }, { params: t.Object({ id: t.String() }), detail: { summary: "Get API Key (full)", tags: ["api-key"] }, }) .post("/api-keys", async ({ body, set }) => { try { const rawKey = "ak_" + crypto.randomUUID().replace(/-/g, ""); const key = await prisma.apiKey.create({ data: { name: body.name, key: rawKey } }); return { success: true, message: "API key berhasil dibuat", data: { ...key, key: rawKey }, }; } catch (error) { set.status = 500; return { success: false, message: "Gagal membuat API key" }; } }, { body: t.Object({ name: t.String({ description: "Nama key" }) }), detail: { summary: "Buat API Key", tags: ["api-key"] }, }) .patch("/api-keys/:id", async ({ params, body, set }) => { try { const key = await prisma.apiKey.update({ where: { id: params.id }, data: { isActive: body.isActive }, }); return { success: true, message: "API key berhasil diupdate", data: { ...key, key: key.key.slice(0, 8) + "••••••••" + key.key.slice(-4) }, }; } catch (error) { set.status = 500; return { success: false, message: "Gagal mengupdate API key" }; } }, { params: t.Object({ id: t.String() }), body: t.Object({ isActive: t.Boolean({ description: "Status aktif" }) }), detail: { summary: "Toggle API Key", tags: ["api-key"] }, }) .delete("/api-keys/:id", async ({ params, set }) => { try { await prisma.apiKey.delete({ where: { id: params.id } }); return { success: true, message: "API key berhasil dihapus" }; } catch (error) { set.status = 500; return { success: false, message: "Gagal menghapus API key" }; } }, { params: t.Object({ id: t.String() }), detail: { summary: "Hapus API Key", tags: ["api-key"] }, }); export const GET = MonitoringServer.handle; export const POST = MonitoringServer.handle; export const PATCH = MonitoringServer.handle; export const DELETE = MonitoringServer.handle; export const OPTIONS = MonitoringServer.handle;