Compare commits

...

10 Commits

4 changed files with 212 additions and 228 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "sistem-desa-mandiri",
"version": "0.1.16",
"version": "0.1.18",
"private": true,
"scripts": {
"dev": "next dev --experimental-https",

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -117,50 +117,32 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
)
.get("/daily-activity", async ({ query, set }) => {
try {
// const data = await prisma.userLog.findMany({
// where: {
// User: {
// Village: {
// isDummy: false
// }
// },
// createdAt: {
// gte: moment().subtract(7, 'days').toDate(),
// lte: moment().toDate(),
// }
// },
// select: {
// createdAt: true,
// }
// })
const VALID_RANGES = [7, 30, 90];
const range = VALID_RANGES.includes(Number(query.range)) ? Number(query.range) : 7;
const data = await prisma.$queryRaw`
SELECT
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() - INTERVAL '7 days'
AND ul."createdAt" >= NOW() - (${range} * INTERVAL '1 day')
GROUP BY tanggal
ORDER BY tanggal;` as any[];
const result = [];
// ubah data ke map biar gampang lookup
const map = data.reduce((acc: any, item: any) => {
const key = moment(item.tanggal).format('YYYY-MM-DD');
acc[key] = Number(item.total);
return acc;
}, {});
// generate 7 hari terakhir
for (let i = 6; i >= 0; i--) {
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
@@ -183,45 +165,39 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
}
},
{
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.",
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 villages = await prisma.village.findMany({
where: { isDummy: false },
select: { name: true },
});
const VALID_RANGES = [7, 30, 90];
const range = VALID_RANGES.includes(Number(query.range)) ? Number(query.range) : 7;
const data = await prisma.$queryRaw`
SELECT
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() - INTERVAL '7 days'
AND ul."createdAt" >= NOW() - (${range} * INTERVAL '1 day')
GROUP BY v."id", v."name"
ORDER BY total_logs DESC;
` as any[];
const logMap: Record<string, number> = {};
data.forEach((item) => {
logMap[item.name] = Number(item.total_logs);
});
const result = villages.map((v) => ({
village: v.name,
activity: logMap[v.name] || 0,
const result = data.map((item: any) => ({
village: item.name,
activity: Number(item.total_logs),
}));
return {
success: true,
message: "Berhasil mendapatkan data",
@@ -238,9 +214,12 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
}
},
{
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 selama 7 hari terakhir.",
description: "Menu Overview - Mendapatkan data grafik perbandingan aktivitas desa. Gunakan ?range=30 atau ?range=90 untuk rentang lebih panjang.",
tags: ["overview"],
},
}
@@ -654,7 +633,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
}
)
.get("/graph-log-villages", async ({ query, set }) => {
const { id, time } = query;
const { id, time, dateFrom, dateTo } = query;
try {
const village = await prisma.village.findUnique({
@@ -672,14 +651,20 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
const now = new Date();
let startDate: Date;
let endDate: Date = now;
const useCustomRange = !!(dateFrom && dateTo);
if (time === "daily") {
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 (opsional)
startDate = new Date(now.getFullYear() - 4, 0, 1); // 5 tahun terakhir
} else {
startDate = new Date(0);
}
@@ -688,6 +673,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
where: {
createdAt: {
gte: startDate,
...(useCustomRange ? { lte: endDate } : {}),
},
User: {
idVillage: id,
@@ -703,21 +689,27 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
// =========================
const map: Record<string, number> = {};
// 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 (time === "daily") {
if (effectiveTime === "daily") {
label = date.toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
});
} else if (time === "monthly") {
} else if (effectiveTime === "monthly") {
label = date.toLocaleDateString("id-ID", {
month: "short",
year: "numeric",
});
} else if (time === "yearly") {
} else if (effectiveTime === "yearly") {
label = date.getFullYear().toString();
}
@@ -729,9 +721,13 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
// =========================
let result: any[] = [];
if (time === "daily") {
for (let i = 13; i >= 0; i--) {
const d = new Date();
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", {
@@ -744,41 +740,38 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
aktivitas: map[label] || 0,
});
}
} else if (time === "monthly") {
const year = now.getFullYear();
for (let m = 0; m <= 11; m++) {
const d = new Date(year, m, 1);
} else if (effectiveTime === "monthly") {
const s = new Date(startDate);
const e = new Date(endDate);
const months: string[] = [];
const label = d.toLocaleDateString("id-ID", {
const cursor = new Date(s.getFullYear(), s.getMonth(), 1);
while (cursor <= e) {
months.push(cursor.toLocaleDateString("id-ID", {
month: "short",
});
result.push({
label,
aktivitas: map[label] || 0,
});
year: "numeric",
}));
cursor.setMonth(cursor.getMonth() + 1);
}
} else if (time === "yearly") {
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 }
];
result = [{ label: currentYear.toString(), aktivitas: 0 }];
} else {
const minYear = Math.min(...years);
const maxYear = Math.max(...years);
result = [];
for (let y = minYear; y <= maxYear; y++) {
const label = y.toString();
result.push({
label,
aktivitas: map[label] || 0,
label: y.toString(),
aktivitas: map[y.toString()] || 0,
});
}
}
@@ -802,21 +795,81 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
{
query: t.Object({
id: t.String({ description: "ID desa" }),
time: t.Enum(
{
daily: "daily",
monthly: "monthly",
yearly: "yearly",
},
{
description: "Rentang waktu (daily = 14 hari, monthly = 1 tahun, yearly = per tahun)",
}
),
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)",
"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"],
},
}
@@ -1061,37 +1114,31 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
}
)
.get("/log-all-villages", async ({ query, set }) => {
const { page = 1, search } = query;
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: {
...(search && {
OR: [
{
User: {
name: {
contains: search,
mode: "insensitive",
},
},
},
{
User: {
Village: {
name: {
contains: search,
mode: "insensitive",
},
},
},
},
],
}),
},
where: whereClause,
select: {
id: true,
createdAt: true,
@@ -1115,32 +1162,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
take,
});
const total = await prisma.userLog.count({
where: {
...(search && {
OR: [
{
User: {
name: {
contains: search,
mode: "insensitive",
},
},
},
{
User: {
Village: {
name: {
contains: search,
mode: "insensitive",
},
},
},
},
],
}),
},
});
const total = await prisma.userLog.count({ where: whereClause });
const result = dataLog.map((item) => ({
id: item.id,
@@ -1175,65 +1197,49 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
{
query: t.Object({
page: t.Optional(t.String({ description: "Halaman" })),
search: t.Optional(t.String({ description: "Pencarian" })),
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 berdasarkan halaman dan pencarian",
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 } = query;
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: {
...(search && {
OR: [
{
name: {
contains: search,
mode: "insensitive",
},
},
{
phone: {
contains: search,
mode: "insensitive",
},
},
{
email: {
contains: search,
mode: "insensitive",
},
},
{
nik: {
contains: search,
mode: "insensitive",
},
},
{
Village: {
name: {
contains: search,
mode: "insensitive",
},
},
},
{
idUserRole: search,
},
],
}),
},
where: whereClause,
select: {
id: true,
name: true,
@@ -1270,55 +1276,13 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
},
},
orderBy: {
createdAt: "desc",
[safeOrderBy]: safeOrderDir,
},
skip,
take,
});
const total = await prisma.user.count({
where: {
...(search && {
OR: [
{
name: {
contains: search,
mode: "insensitive",
},
},
{
phone: {
contains: search,
mode: "insensitive",
},
},
{
email: {
contains: search,
mode: "insensitive",
},
},
{
nik: {
contains: search,
mode: "insensitive",
},
},
{
Village: {
name: {
contains: search,
mode: "insensitive",
},
},
},
{
idUserRole: search,
},
],
}),
},
});
const total = await prisma.user.count({ where: whereClause });
const result = data.map((item) => ({
id: item.id,
@@ -1363,12 +1327,16 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
{
query: t.Object({
page: t.Optional(t.String({ description: "Halaman" })),
search: t.Optional(t.String({ description: "Pencarian" })),
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 berdasarkan halaman dan pencarian",
description: "Mendapatkan data user dengan filter status, role, desa, pencarian, dan pengurutan",
tags: ["user"],
},
}
@@ -1554,6 +1522,20 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
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, "");