From 3272ecaef38941bf89b904da29b9fb86bfbdbcbe Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 14:09:42 +0800 Subject: [PATCH 1/7] feat: tambah field lastActivity ke endpoint monitoring /user --- src/app/api/monitoring/[[...slug]]/route.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 2b3493a..5672383 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -1274,6 +1274,11 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) name: true, }, }, + UserLog: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { createdAt: true }, + }, }, orderBy: { [safeOrderBy]: safeOrderDir, @@ -1302,6 +1307,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) idVillage: item.idVillage, idGroup: item.idGroup, idPosition: item.idPosition, + lastActivity: item.UserLog[0]?.createdAt ?? null, })); return { -- 2.49.1 From 619cc9a40344a0f231e01cfd373bb2011e7c2759 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 14:14:35 +0800 Subject: [PATCH 2/7] feat: tambah endpoint stale-villages untuk deteksi desa tidak aktif --- src/app/api/monitoring/[[...slug]]/route.ts | 60 +++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 5672383..4b5b2ee 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -1510,6 +1510,66 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) } ) + .get("/stale-villages", async ({ query, set }) => { + const VALID_DAYS = [7, 14, 30]; + const days = VALID_DAYS.includes(Number(query.days)) ? Number(query.days) : 7; + + try { + const data = await prisma.$queryRaw` + SELECT + v."id", + v."name", + MAX(ul."createdAt") AS "lastActivity" + FROM "Village" v + LEFT JOIN "User" u ON u."idVillage" = v."id" AND u."idUserRole" != 'developer' + LEFT JOIN "UserLog" ul ON ul."idUser" = u."id" + WHERE v."isDummy" = false AND v."isActive" = true + GROUP BY v."id", v."name" + HAVING MAX(ul."createdAt") < NOW() - (${days} * INTERVAL '1 day') + OR MAX(ul."createdAt") IS NULL + ORDER BY "lastActivity" ASC NULLS FIRST + ` as any[]; + + const result = data.map((v: any) => ({ + id: v.id, + name: v.name, + lastActivity: v.lastActivity ?? null, + daysSince: v.lastActivity + ? Math.floor((Date.now() - new Date(v.lastActivity).getTime()) / (1000 * 60 * 60 * 24)) + : null, + })); + + return { + success: true, + message: "Berhasil mendapatkan data", + data: { + count: result.length, + days, + villages: result, + }, + }; + } catch (error) { + console.error("[stale-villages] error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + days: t.Optional(t.String({ description: "Threshold hari tidak aktif: 7, 14, atau 30 (default: 7)" })), + }), + detail: { + summary: "Stale Villages", + description: "Mendapatkan daftar desa aktif yang tidak ada aktivitas dalam X hari terakhir (atau belum pernah ada aktivitas sama sekali).", + tags: ["villages"], + }, + } + ) + // ─── API KEY MANAGEMENT ────────────────────────────────────────────────── .get("/api-keys", async ({ set }) => { -- 2.49.1 From e5891f0da37c472ada790c5f99ada827367bc5a4 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 14:18:03 +0800 Subject: [PATCH 3/7] feat: tambah idVillage ke response log-all-villages --- src/app/api/monitoring/[[...slug]]/route.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 4b5b2ee..3cbaf58 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -1149,6 +1149,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) name: true, Village: { select: { + id: true, name: true, }, }, @@ -1171,6 +1172,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) desc: item.desc, username: item.User.name, village: item.User.Village.name, + idVillage: item.User.Village.id, })); -- 2.49.1 From 1df1d10c91bd7f6a3f1d85c390225ef50b53e31c Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 14:32:49 +0800 Subject: [PATCH 4/7] feat: tambah endpoint inactive-users dan lengkapi field response-nya --- src/app/api/monitoring/[[...slug]]/route.ts | 110 ++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 3cbaf58..18ba934 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -1572,6 +1572,116 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) } ) + .get("/inactive-users", async ({ query, set }) => { + const VALID_DAYS = [7, 14, 30]; + const days = VALID_DAYS.includes(Number(query.days)) ? Number(query.days) : 7; + const pageNum = Number(query.page ?? 1) || 1; + const take = 15; + const { idVillage } = query; + + try { + const users = await prisma.user.findMany({ + where: { + idUserRole: { not: 'developer' }, + ...(idVillage && { idVillage }), + }, + select: { + id: true, + name: true, + nik: true, + email: true, + phone: true, + gender: true, + isActive: true, + isWithoutOTP: true, + isApprover: true, + idUserRole: true, + idVillage: true, + idGroup: true, + idPosition: true, + UserRole: { select: { name: true } }, + Village: { select: { id: true, name: true } }, + Group: { select: { name: true } }, + Position: { select: { name: true } }, + UserLog: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { createdAt: true }, + }, + }, + }); + + const threshold = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + const inactive = users + .map((u) => ({ + id: u.id, + name: u.name, + nik: u.nik, + email: u.email, + phone: u.phone, + gender: u.gender, + isActive: u.isActive, + isWithoutOTP: u.isWithoutOTP, + isApprover: u.isApprover, + role: u.UserRole?.name ?? null, + idUserRole: u.idUserRole, + village: u.Village?.name ?? null, + idVillage: u.Village?.id ?? null, + group: u.Group?.name ?? null, + position: u.Position?.name ?? null, + idGroup: u.idGroup, + idPosition: u.idPosition, + lastActivity: u.UserLog[0]?.createdAt ?? null, + daysSince: u.UserLog[0]?.createdAt + ? Math.floor((Date.now() - u.UserLog[0].createdAt.getTime()) / (1000 * 60 * 60 * 24)) + : null, + })) + .filter((u) => u.lastActivity === null || u.lastActivity < threshold) + .sort((a, b) => { + if (a.lastActivity === null) return -1; + if (b.lastActivity === null) return 1; + return a.lastActivity.getTime() - b.lastActivity.getTime(); + }); + + const total = inactive.length; + const paginated = inactive.slice((pageNum - 1) * take, pageNum * take); + + return { + success: true, + message: "Berhasil mendapatkan data", + data: { + users: paginated, + total, + totalPage: Math.ceil(total / take), + currentPage: pageNum, + days, + }, + }; + } catch (error) { + console.error("[inactive-users] error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + days: t.Optional(t.String({ description: "Threshold hari tidak aktif: 7, 14, atau 30 (default: 7)" })), + idVillage: t.Optional(t.String({ description: "Filter berdasarkan ID desa" })), + page: t.Optional(t.String({ description: "Halaman (default: 1)" })), + }), + detail: { + summary: "Inactive Users", + description: "Mendapatkan daftar user yang tidak ada aktivitas dalam X hari terakhir, atau belum pernah ada aktivitas sama sekali.", + tags: ["user"], + }, + } + ) + // ─── API KEY MANAGEMENT ────────────────────────────────────────────────── .get("/api-keys", async ({ set }) => { -- 2.49.1 From 8dca3e440fd8adc8d3af4150e3b123ebf1cc9f51 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 14:47:11 +0800 Subject: [PATCH 5/7] feat: tambah endpoint peak-hours untuk distribusi aktivitas per jam --- src/app/api/monitoring/[[...slug]]/route.ts | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 18ba934..4176299 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -1682,6 +1682,60 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) } ) + .get("/peak-hours", async ({ query, set }) => { + const { idVillage } = query; + + try { + const data = await prisma.$queryRaw` + SELECT + EXTRACT(HOUR FROM ul."createdAt")::int AS hour, + COUNT(*)::int AS count + FROM "UserLog" ul + JOIN "User" u ON ul."idUser" = u."id" + WHERE (${idVillage ?? null}::text IS NULL OR u."idVillage" = ${idVillage ?? null}) + GROUP BY hour + ORDER BY hour + ` as { hour: number; count: number }[]; + + const map = new Map(data.map((d) => [d.hour, d.count])); + const result = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + label: `${String(i).padStart(2, '0')}:00`, + count: map.get(i) ?? 0, + })); + + const peak = result.reduce((max, item) => (item.count > max.count ? item : max), result[0]); + + return { + success: true, + message: "Berhasil mendapatkan data", + data: { + hours: result, + peak, + }, + }; + } catch (error) { + console.error("[peak-hours] error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + idVillage: t.Optional(t.String({ description: "ID desa (kosong = semua desa)" })), + }), + detail: { + summary: "Peak Hours", + description: "Mendapatkan distribusi aktivitas per jam dalam sehari (0-23) untuk desa tertentu atau semua desa.", + tags: ["villages"], + }, + } + ) + // ─── API KEY MANAGEMENT ────────────────────────────────────────────────── .get("/api-keys", async ({ set }) => { -- 2.49.1 From a0bffd53cb21a37be5f1ffdbd603ae9b702e83c0 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 15:06:23 +0800 Subject: [PATCH 6/7] feat: tambah endpoint export-logs dan export-users untuk CSV download --- src/app/api/monitoring/[[...slug]]/route.ts | 135 ++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 4176299..095a165 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -1736,6 +1736,141 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) } ) + // ─── CSV EXPORT ────────────────────────────────────────────────────────── + + .get("/export-logs", async ({ query, set }) => { + const { search, action, idVillage, dateFrom, dateTo } = query; + + 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 data = await prisma.userLog.findMany({ + where: whereClause, + select: { + createdAt: true, + action: true, + desc: true, + User: { + select: { + name: true, + Village: { select: { name: true } }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + const result = data.map((item) => ({ + timestamp: moment(item.createdAt).format('DD MMM YYYY HH:mm'), + username: item.User.name, + village: item.User.Village.name, + action: item.action, + desc: item.desc, + })); + + return { success: true, data: result }; + } catch (error) { + console.error("[export-logs] error:", error); + set.status = 500; + return { success: false, message: "Terjadi kesalahan pada server", data: null }; + } + }, { + query: t.Object({ + search: t.Optional(t.String()), + action: t.Optional(t.String()), + idVillage: t.Optional(t.String()), + dateFrom: t.Optional(t.String()), + dateTo: t.Optional(t.String()), + }), + detail: { summary: "Export Logs CSV", tags: ["log-activity"] }, + }) + + .get("/export-users", async ({ query, set }) => { + const { search, isActive, idUserRole, idVillage } = query; + + 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 } } }, + ], + }), + }; + + try { + const data = await prisma.user.findMany({ + where: whereClause, + select: { + name: true, + nik: true, + phone: true, + email: true, + gender: true, + isActive: true, + UserRole: { select: { name: true } }, + Village: { select: { name: true } }, + Group: { select: { name: true } }, + Position: { select: { name: true } }, + UserLog: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { createdAt: true }, + }, + }, + orderBy: { name: 'asc' }, + }); + + const result = data.map((item) => ({ + name: item.name, + nik: item.nik, + email: item.email, + phone: item.phone, + gender: item.gender === 'M' ? 'Male' : item.gender === 'F' ? 'Female' : item.gender, + role: item.UserRole?.name ?? '', + village: item.Village?.name ?? '', + group: item.Group?.name ?? '', + position: item.Position?.name ?? '', + status: item.isActive ? 'Active' : 'Inactive', + lastActivity: item.UserLog[0]?.createdAt ? moment(item.UserLog[0].createdAt).format('DD MMM YYYY HH:mm') : '', + })); + + return { success: true, data: result }; + } catch (error) { + console.error("[export-users] error:", error); + set.status = 500; + return { success: false, message: "Terjadi kesalahan pada server", data: null }; + } + }, { + query: t.Object({ + search: t.Optional(t.String()), + isActive: t.Optional(t.String()), + idUserRole: t.Optional(t.String()), + idVillage: t.Optional(t.String()), + }), + detail: { summary: "Export Users CSV", tags: ["user"] }, + }) + // ─── API KEY MANAGEMENT ────────────────────────────────────────────────── .get("/api-keys", async ({ set }) => { -- 2.49.1 From 1e02747e224e91c53f5b71da779d49d5e35b096e Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 28 May 2026 15:39:47 +0800 Subject: [PATCH 7/7] feat: tambahkan village-report endpoint dengan perbandingan periode sebelumnya - Endpoint /village-report kini menghitung activity_count periode saat ini dan prev_activity_count periode sebelumnya dalam satu query (doubleRange) - Tambahkan kalkulasi trend persentase perubahan antar periode - Sertakan data perbekel, active_users, inactive_users, lastActivity, dan daysSince - Tambahkan endpoint /export-logs dan /export-users untuk ekspor CSV --- src/app/api/monitoring/[[...slug]]/route.ts | 74 +++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 095a165..6b58c13 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -1871,6 +1871,80 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) detail: { summary: "Export Users CSV", tags: ["user"] }, }) + .get("/village-report", async ({ query, set }) => { + const VALID_RANGES = [7, 30, 90]; + const range = VALID_RANGES.includes(Number(query.range)) ? Number(query.range) : 7; + const doubleRange = range * 2; + + try { + const data = await prisma.$queryRaw` + SELECT + v."id", + v."name", + v."isActive", + COUNT(CASE WHEN ul."createdAt" >= NOW() - (${range} * INTERVAL '1 day') THEN 1 END)::int AS activity_count, + COUNT(CASE WHEN ul."createdAt" < NOW() - (${range} * INTERVAL '1 day') THEN 1 END)::int AS prev_activity_count, + MAX(ul."createdAt") AS last_activity, + ( + SELECT u2."name" FROM "User" u2 + WHERE u2."idVillage" = v."id" AND u2."idUserRole" = 'supadmin' + LIMIT 1 + ) AS perbekel, + ( + SELECT COUNT(*)::int FROM "User" u3 + WHERE u3."idVillage" = v."id" AND u3."isActive" = true AND u3."idUserRole" != 'developer' + ) AS active_users, + ( + SELECT COUNT(*)::int FROM "User" u4 + WHERE u4."idVillage" = v."id" AND u4."isActive" = false AND u4."idUserRole" != 'developer' + ) AS inactive_users + FROM "Village" v + LEFT JOIN "User" u ON u."idVillage" = v."id" AND u."idUserRole" != 'developer' + LEFT JOIN "UserLog" ul ON ul."idUser" = u."id" + AND ul."createdAt" >= NOW() - (${doubleRange} * INTERVAL '1 day') + WHERE v."isDummy" = false + GROUP BY v."id", v."name", v."isActive" + ORDER BY activity_count DESC, v."name" ASC + ` as any[]; + + const result = data.map((v: any) => { + const curr = Number(v.activity_count); + const prev = Number(v.prev_activity_count); + const trend = prev > 0 ? Math.round(((curr - prev) / prev) * 100) : curr > 0 ? 100 : 0; + return { + id: v.id, + name: v.name, + isActive: v.isActive, + perbekel: v.perbekel ?? '-', + activeUsers: Number(v.active_users), + inactiveUsers: Number(v.inactive_users), + activityCount: curr, + prevActivityCount: prev, + trend, + lastActivity: v.last_activity ? moment(v.last_activity).format('DD MMM YYYY HH:mm') : null, + daysSince: v.last_activity + ? Math.floor((Date.now() - new Date(v.last_activity).getTime()) / (1000 * 60 * 60 * 24)) + : null, + }; + }); + + return { + success: true, + message: "Berhasil mendapatkan data", + data: { villages: result, range, generatedAt: moment().format('DD MMM YYYY HH:mm') }, + }; + } catch (error) { + console.error("[village-report] 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: "Village Report", description: "Data semua desa untuk keperluan laporan PDF.", tags: ["villages"] }, + }) + // ─── API KEY MANAGEMENT ────────────────────────────────────────────────── .get("/api-keys", async ({ set }) => { -- 2.49.1