Compare commits

...

14 Commits

Author SHA1 Message Date
552957282b bump: version 0.1.18 + migration 2026-05-25 15:36:30 +08:00
22555079f3 feat: graph-log-villages support dateFrom/dateTo + recent-village-logs endpoint 2026-05-25 15:08:30 +08:00
6cf6486172 feat: tambah endpoint GET /api-keys/:id untuk ambil full key 2026-05-25 12:00:53 +08:00
35e51028db Merge pull request 'amalia/22-mei-26' (#52) from amalia/22-mei-26 into join
Reviewed-on: #52
2026-05-22 17:41:21 +08:00
37ea4e37e7 bump: version 0.1.17 + migration 2026-05-22 14:46:33 +08:00
e270db3bfa feat: add range param to daily-activity and comparison-activity endpoints
Both endpoints now accept ?range=7|30|90 (default 7).
comparison-activity result now follows SQL ORDER BY instead of being
remapped through villages array.
2026-05-22 14:16:36 +08:00
32dac32532 feat: add village and date range filter on /log-all-villages endpoint 2026-05-22 11:37:42 +08:00
d369a71eb6 feat: add filter and orderBy support on /user monitoring endpoint 2026-05-22 11:17:42 +08:00
7334831d61 Merge pull request 'bump: version 0.1.16 + migration' (#51) from amalia/21-mei-26 into join
Reviewed-on: #51
2026-05-21 17:25:52 +08:00
c0a4d584af bump: version 0.1.16 + migration 2026-05-21 11:07:23 +08:00
9ac105e7bc Merge pull request 'amalia/20-mei-26' (#50) from amalia/20-mei-26 into join
Reviewed-on: #50
2026-05-20 17:22:20 +08:00
10457e96e8 feat: tambah autentikasi x-api-key pada NOC API dan ekstrak isValidApiKey ke shared lib 2026-05-20 12:23:38 +08:00
9ad934c99f bump: version 0.1.15 + migration 2026-05-19 16:05:35 +08:00
5bfcde32ed Merge pull request 'amalia/18-mei-26' (#49) from amalia/18-mei-26 into join
Reviewed-on: #49
2026-05-18 17:26:42 +08:00
9 changed files with 252 additions and 243 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "sistem-desa-mandiri",
"version": "0.1.14",
"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

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

View File

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

View File

@@ -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: "*",

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, "");

View File

@@ -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
View 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);
}