Compare commits
21 Commits
amalia/13-
...
amalia/25-
| Author | SHA1 | Date | |
|---|---|---|---|
| 552957282b | |||
| 22555079f3 | |||
| 6cf6486172 | |||
| 35e51028db | |||
| 37ea4e37e7 | |||
| e270db3bfa | |||
| 32dac32532 | |||
| d369a71eb6 | |||
| 7334831d61 | |||
| c0a4d584af | |||
| 9ac105e7bc | |||
| 10457e96e8 | |||
| 9ad934c99f | |||
| 5bfcde32ed | |||
| 8240d608ad | |||
| fd7d08d38a | |||
| b95fd9543c | |||
| 7622c58ce4 | |||
| d1b90b63e9 | |||
| 387a86f17e | |||
| b749b333f6 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sistem-desa-mandiri",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.18",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --experimental-https",
|
||||
|
||||
1
prisma/migrations/20260515030040_auto/migration.sql
Normal file
1
prisma/migrations/20260515030040_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
1
prisma/migrations/20260515031651_auto/migration.sql
Normal file
1
prisma/migrations/20260515031651_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
1
prisma/migrations/20260518071507_auto/migration.sql
Normal file
1
prisma/migrations/20260518071507_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
1
prisma/migrations/20260519080535_auto/migration.sql
Normal file
1
prisma/migrations/20260519080535_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
1
prisma/migrations/20260521030721_auto/migration.sql
Normal file
1
prisma/migrations/20260521030721_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
1
prisma/migrations/20260522064632_auto/migration.sql
Normal file
1
prisma/migrations/20260522064632_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
1
prisma/migrations/20260525073630_auto/migration.sql
Normal file
1
prisma/migrations/20260525073630_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isValidApiKey } from "@/lib/apiKey";
|
||||
import { prisma } from "@/module/_global";
|
||||
import cors from "@elysiajs/cors";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
@@ -6,20 +7,6 @@ import _ from "lodash";
|
||||
import moment from "moment";
|
||||
import "moment/locale/id";
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
let apiKeyCache: Set<string> = new Set();
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
async function isValidApiKey(incoming: string): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
if (now > cacheExpiresAt) {
|
||||
const rows = await prisma.apiKey.findMany({ where: { isActive: true }, select: { key: true } });
|
||||
apiKeyCache = new Set(rows.map((r) => r.key));
|
||||
cacheExpiresAt = now + CACHE_TTL_MS;
|
||||
}
|
||||
return apiKeyCache.has(incoming);
|
||||
}
|
||||
|
||||
const AiServer = new Elysia({ prefix: "/api/ai" })
|
||||
.use(cors({
|
||||
origin: "*",
|
||||
|
||||
@@ -96,13 +96,13 @@ async function sendNotification({
|
||||
}
|
||||
}
|
||||
|
||||
async function getApproversInVillage(idVillage: string): Promise<NotifTarget[]> {
|
||||
async function getApproversInVillage(idVillage: string, idGroup: string): Promise<NotifTarget[]> {
|
||||
const approvers = await prisma.user.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
idVillage,
|
||||
OR: [
|
||||
{ isApprover: true },
|
||||
{ isApprover: true, idGroup },
|
||||
{ UserRole: { id: 'supadmin' } }
|
||||
]
|
||||
},
|
||||
@@ -198,7 +198,10 @@ export async function POST(request: Request, context: { params: { id: string } }
|
||||
|
||||
const task = await prisma.projectTask.findUnique({
|
||||
where: { id, isActive: true },
|
||||
select: { id: true, status: true, idProject: true, title: true }
|
||||
select: {
|
||||
id: true, status: true, title: true,
|
||||
Project: { select: { id: true, idGroup: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
@@ -227,14 +230,14 @@ export async function POST(request: Request, context: { params: { id: string } }
|
||||
})
|
||||
]);
|
||||
|
||||
await recalculateProjectStatus(task.idProject);
|
||||
await recalculateProjectStatus(task.Project.id);
|
||||
|
||||
// Notifikasi ke semua approver
|
||||
const approverTargets = await getApproversInVillage(String(userMobile.idVillage));
|
||||
// Notifikasi ke semua approver di desa dan group yang sama
|
||||
const approverTargets = await getApproversInVillage(String(userMobile.idVillage), task.Project.idGroup);
|
||||
await sendNotification({
|
||||
targets: approverTargets,
|
||||
idUserFrom: userMobile.id,
|
||||
idContent: task.idProject,
|
||||
idContent: task.Project.id,
|
||||
title: 'Pengajuan Penyelesaian Tugas',
|
||||
desc: task.title,
|
||||
});
|
||||
@@ -271,7 +274,7 @@ export async function PUT(request: Request, context: { params: { id: string } })
|
||||
|
||||
const task = await prisma.projectTask.findUnique({
|
||||
where: { id, isActive: true },
|
||||
select: { id: true, status: true, idProject: true, title: true }
|
||||
select: { id: true, status: true, title: true, Project: { select: { id: true } } }
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
@@ -304,7 +307,7 @@ export async function PUT(request: Request, context: { params: { id: string } })
|
||||
})
|
||||
]);
|
||||
|
||||
await recalculateProjectStatus(task.idProject);
|
||||
await recalculateProjectStatus(task.Project.id);
|
||||
|
||||
// Notifikasi ke submitter
|
||||
const submitterTarget = await getUserNotifTarget(pendingApproval.idUser);
|
||||
@@ -312,7 +315,7 @@ export async function PUT(request: Request, context: { params: { id: string } })
|
||||
await sendNotification({
|
||||
targets: [submitterTarget],
|
||||
idUserFrom: userMobile.id,
|
||||
idContent: task.idProject,
|
||||
idContent: task.Project.id,
|
||||
title: 'Tugas Disetujui',
|
||||
desc: task.title,
|
||||
});
|
||||
@@ -339,7 +342,7 @@ export async function PUT(request: Request, context: { params: { id: string } })
|
||||
})
|
||||
]);
|
||||
|
||||
await recalculateProjectStatus(task.idProject);
|
||||
await recalculateProjectStatus(task.Project.id);
|
||||
|
||||
// Notifikasi ke submitter
|
||||
const submitterTarget = await getUserNotifTarget(pendingApproval.idUser);
|
||||
@@ -347,7 +350,7 @@ export async function PUT(request: Request, context: { params: { id: string } })
|
||||
await sendNotification({
|
||||
targets: [submitterTarget],
|
||||
idUserFrom: userMobile.id,
|
||||
idContent: task.idProject,
|
||||
idContent: task.Project.id,
|
||||
title: 'Tugas Ditolak',
|
||||
desc: task.title,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,9 @@ export async function GET(request: Request, context: { params: { id: string } })
|
||||
where: {
|
||||
id: String(id),
|
||||
isActive: true
|
||||
},
|
||||
include: {
|
||||
Division: { select: { idGroup: true } }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,7 +36,7 @@ export async function GET(request: Request, context: { params: { id: string } })
|
||||
}
|
||||
|
||||
if (kategori == "data") {
|
||||
allData = data
|
||||
allData = { ...data, idGroup: data.Division.idGroup }
|
||||
} else if (kategori == "progress") {
|
||||
const dataProgress = await prisma.divisionProjectTask.findMany({
|
||||
where: {
|
||||
|
||||
@@ -96,13 +96,19 @@ async function sendNotification({
|
||||
}
|
||||
|
||||
async function getApproversForDivision(idVillage: string, idDivision: string): Promise<NotifTarget[]> {
|
||||
const division = await prisma.division.findUnique({
|
||||
where: { id: idDivision },
|
||||
select: { idGroup: true }
|
||||
});
|
||||
const idGroup = division?.idGroup;
|
||||
|
||||
const [globalApprovers, divisionAdmins] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
idVillage,
|
||||
OR: [
|
||||
{ isApprover: true },
|
||||
{ isApprover: true, idGroup },
|
||||
{ UserRole: { id: 'supadmin' } }
|
||||
]
|
||||
},
|
||||
@@ -285,23 +291,35 @@ export async function PUT(request: Request, context: { params: { id: string } })
|
||||
return NextResponse.json({ success: false, message: "Anda harus login untuk mengakses ini" }, { status: 200 });
|
||||
}
|
||||
|
||||
const canApprove = await getApproverStatus(userMobile.id);
|
||||
if (!canApprove) {
|
||||
// Check if division admin
|
||||
const task = await prisma.divisionProjectTask.findUnique({
|
||||
where: { id, isActive: true },
|
||||
select: { idDivision: true }
|
||||
});
|
||||
if (task) {
|
||||
const isDivAdmin = await prisma.divisionMember.count({
|
||||
where: { idDivision: task.idDivision, idUser: userMobile.id, isAdmin: true, isActive: true }
|
||||
});
|
||||
if (isDivAdmin === 0) {
|
||||
return NextResponse.json({ success: false, message: "Anda tidak memiliki izin untuk menyetujui atau menolak tugas" }, { status: 200 });
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
|
||||
}
|
||||
const taskForAuth = await prisma.divisionProjectTask.findUnique({
|
||||
where: { id, isActive: true },
|
||||
select: { idDivision: true }
|
||||
});
|
||||
if (!taskForAuth) {
|
||||
return NextResponse.json({ success: false, message: "Tugas tidak ditemukan" }, { status: 200 });
|
||||
}
|
||||
|
||||
const [division, userFull, isDivAdmin] = await Promise.all([
|
||||
prisma.division.findUnique({
|
||||
where: { id: taskForAuth.idDivision },
|
||||
select: { idGroup: true, idVillage: true }
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userMobile.id },
|
||||
select: { isApprover: true, idGroup: true, idVillage: true, UserRole: { select: { id: true } } }
|
||||
}),
|
||||
prisma.divisionMember.count({
|
||||
where: { idDivision: taskForAuth.idDivision, idUser: userMobile.id, isAdmin: true, isActive: true }
|
||||
})
|
||||
]);
|
||||
|
||||
const isSupadmin = APPROVER_ROLES.includes(userFull?.UserRole?.id ?? '');
|
||||
const isGroupApprover = !!(userFull?.isApprover &&
|
||||
userFull.idVillage === division?.idVillage &&
|
||||
userFull.idGroup === division?.idGroup);
|
||||
|
||||
if (!isSupadmin && !isGroupApprover && isDivAdmin === 0) {
|
||||
return NextResponse.json({ success: false, message: "Anda tidak memiliki izin untuk menyetujui atau menolak tugas" }, { status: 200 });
|
||||
}
|
||||
|
||||
const task = await prisma.divisionProjectTask.findUnique({
|
||||
|
||||
@@ -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,
|
||||
@@ -1242,6 +1248,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
|
||||
email: true,
|
||||
isWithoutOTP: true,
|
||||
isActive: true,
|
||||
isApprover: true,
|
||||
idUserRole: true,
|
||||
idVillage: true,
|
||||
idGroup: true,
|
||||
@@ -1269,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,
|
||||
@@ -1328,6 +1293,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
|
||||
gender: item.gender,
|
||||
isWithoutOTP: item.isWithoutOTP,
|
||||
isActive: item.isActive,
|
||||
isApprover: item.isApprover,
|
||||
role: item.UserRole?.name,
|
||||
village: item.Village?.name,
|
||||
group: item.Group?.name,
|
||||
@@ -1361,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"],
|
||||
},
|
||||
}
|
||||
@@ -1440,7 +1410,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
|
||||
}
|
||||
)
|
||||
.post("/edit-user", async ({ body, set }) => {
|
||||
const { id, name, nik, phone, email, gender, idUserRole, idVillage, idGroup, idPosition, isActive, isWithoutOTP } = body;
|
||||
const { id, name, nik, phone, email, gender, idUserRole, idVillage, idGroup, idPosition, isActive, isWithoutOTP, isApprover } = body;
|
||||
|
||||
try {
|
||||
const cekId = await prisma.user.findFirst({
|
||||
@@ -1492,6 +1462,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
|
||||
idPosition,
|
||||
isActive,
|
||||
isWithoutOTP,
|
||||
isApprover,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1522,6 +1493,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
|
||||
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",
|
||||
@@ -1550,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, "");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isValidApiKey } from "@/lib/apiKey";
|
||||
import { prisma } from "@/module/_global";
|
||||
import cors from "@elysiajs/cors";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
@@ -11,20 +12,40 @@ const NocServer = new Elysia({ prefix: "/api/noc" })
|
||||
.use(cors({
|
||||
origin: "*",
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "x-api-key"],
|
||||
}))
|
||||
.use(swagger({
|
||||
path: "/docs", // Karena prefix instance adalah /api/noc, maka ini akan diakses di /api/noc/docs
|
||||
path: "/docs",
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Sistem Desa Mandiri - NOC API",
|
||||
version: "1.0.0",
|
||||
description: "API Khusus untuk kebutuhan NOC (Network Operation Center) dan Monitoring Desa",
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: {
|
||||
type: "apiKey",
|
||||
in: "header",
|
||||
name: "x-api-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
tags: [
|
||||
{ name: "NOC", description: "Endpoint khusus monitoring" }
|
||||
]
|
||||
}
|
||||
}))
|
||||
.onBeforeHandle(async ({ request, set, path }) => {
|
||||
if (path.startsWith("/api/noc/docs")) return;
|
||||
|
||||
const incoming = request.headers.get("x-api-key");
|
||||
if (!incoming || !(await isValidApiKey(incoming))) {
|
||||
set.status = 401;
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /api/noc/active-divisions ──────────────────────────────────────────
|
||||
.get(
|
||||
|
||||
15
src/lib/apiKey.ts
Normal file
15
src/lib/apiKey.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { prisma } from "@/module/_global";
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
let apiKeyCache: Set<string> = new Set();
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
export async function isValidApiKey(incoming: string): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
if (now > cacheExpiresAt) {
|
||||
const rows = await prisma.apiKey.findMany({ where: { isActive: true }, select: { key: true } });
|
||||
apiKeyCache = new Set(rows.map((r) => r.key));
|
||||
cacheExpiresAt = now + CACHE_TTL_MS;
|
||||
}
|
||||
return apiKeyCache.has(incoming);
|
||||
}
|
||||
Reference in New Issue
Block a user