feat(noc): integrate DocumentStat model and sync with external NOC API

This commit is contained in:
2026-03-31 15:04:51 +08:00
parent 11ef320d55
commit 6ace5b5d1c
9 changed files with 260 additions and 39 deletions

View File

@@ -894,21 +894,30 @@ export interface operations {
};
content: {
"application/json": {
success: boolean;
message: string;
data: {
category: string;
count: number;
label: string;
value: number;
color: string;
}[];
};
"multipart/form-data": {
success: boolean;
message: string;
data: {
category: string;
count: number;
label: string;
value: number;
color: string;
}[];
};
"text/plain": {
success: boolean;
message: string;
data: {
category: string;
count: number;
label: string;
value: number;
color: string;
}[];
};
};

View File

@@ -802,26 +802,38 @@
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
},
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"category",
"count"
"label",
"value",
"color"
],
"properties": {
"category": {
"label": {
"type": "string"
},
"count": {
"value": {
"type": "number"
},
"color": {
"type": "string"
}
}
}
}
},
"required": [
"success",
"message",
"data"
]
}
@@ -830,26 +842,38 @@
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
},
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"category",
"count"
"label",
"value",
"color"
],
"properties": {
"category": {
"label": {
"type": "string"
},
"count": {
"value": {
"type": "number"
},
"color": {
"type": "string"
}
}
}
}
},
"required": [
"success",
"message",
"data"
]
}
@@ -858,26 +882,38 @@
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
},
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"category",
"count"
"label",
"value",
"color"
],
"properties": {
"category": {
"label": {
"type": "string"
},
"count": {
"value": {
"type": "number"
},
"color": {
"type": "string"
}
}
}
}
},
"required": [
"success",
"message",
"data"
]
}

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "document_stat" (
"id" TEXT NOT NULL,
"villageId" TEXT NOT NULL DEFAULT 'desa1',
"label" TEXT NOT NULL,
"value" INTEGER NOT NULL DEFAULT 0,
"color" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "document_stat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "document_stat_villageId_label_key" ON "document_stat"("villageId", "label");

View File

@@ -107,6 +107,19 @@ model Document {
@@map("document")
}
model DocumentStat {
id String @id @default(cuid())
villageId String @default("desa1")
label String
value Int @default(0)
color String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([villageId, label])
@@map("document_stat")
}
model Discussion {
id String @id @default(cuid())
externalId String? @unique // ID asli dari server NOC

View File

@@ -13,6 +13,7 @@ import {
seedDiscussions,
seedDivisionMetrics,
seedDocuments,
seedDocumentStats,
} from "./seeders/seed-discussions";
import {
getDivisionIds,
@@ -102,6 +103,7 @@ export async function runSeed() {
// 5. Seed Documents & Discussions
console.log("📁 [5/7] Documents & Discussions");
await seedDocuments(divisionIds, adminId);
await seedDocumentStats();
await seedDiscussions(divisionIds, adminId);
console.log();
@@ -188,6 +190,7 @@ export async function runSpecificSeeder(name: string) {
const divs = await seedDivisions();
const divIds = divs.map((d) => d.id);
await seedDocuments(divIds, docAdminId);
await seedDocumentStats();
await seedDiscussions(divIds, docAdminId);
break;
}

View File

@@ -70,6 +70,44 @@ export async function seedDocuments(divisionIds: string[], userId: string) {
console.log("✅ Documents seeded successfully");
}
/**
* Seed Document Stats
* Creates aggregate document counts matching user request
*/
export async function seedDocumentStats() {
console.log("Seeding Document Stats...");
const stats = [
{
villageId: "desa1",
label: "Gambar",
value: 389,
color: "#fac858",
},
{
villageId: "desa1",
label: "Dokumen",
value: 147,
color: "#92cc76",
},
];
for (const stat of stats) {
await prisma.documentStat.upsert({
where: {
villageId_label: {
villageId: stat.villageId,
label: stat.label,
},
},
update: stat,
create: stat,
});
}
console.log("✅ Document Stats seeded successfully");
}
/**
* Seed Discussions
* Creates sample discussions for divisions and activities
@@ -115,12 +153,15 @@ export async function seedDiscussions(divisionIds: string[], userId: string) {
// Create parent discussions first
const parentDiscussions = [];
for (let i = 0; i < discussions.length; i += 2) {
const current = discussions[i];
if (!current) continue;
const discussion = await prisma.discussion.create({
data: {
message: discussions[i].message,
senderId: discussions[i].senderId,
divisionId: discussions[i].divisionId,
isResolved: discussions[i].isResolved,
message: current.message,
senderId: current.senderId,
divisionId: current.divisionId,
isResolved: current.isResolved,
},
});
parentDiscussions.push(discussion);
@@ -128,16 +169,20 @@ export async function seedDiscussions(divisionIds: string[], userId: string) {
// Create replies
for (let i = 1; i < discussions.length; i += 2) {
const current = discussions[i];
if (!current) continue;
const parentIndex = Math.floor((i - 1) / 2);
if (parentIndex < parentDiscussions.length) {
const parent = parentDiscussions[parentIndex];
if (parent) {
await prisma.discussion.update({
where: { id: parentDiscussions[parentIndex].id },
where: { id: parent.id },
data: {
replies: {
create: {
message: discussions[i].message,
senderId: discussions[i].senderId,
isResolved: discussions[i].isResolved,
message: current.message,
senderId: current.senderId,
isResolved: current.isResolved,
},
},
},

View File

@@ -226,7 +226,51 @@ async function syncLatestDiscussion() {
}
/**
* 5. Update lastSyncedAt timestamp
* 5. Sync Document Stats (New)
*/
async function syncDocumentStats() {
logger.info("Syncing Document Stats...");
const { data, error } = await nocExternalClient.GET("/api/noc/diagram-jumlah-document", {
params: { query: { idDesa: ID_DESA } },
});
if (error || !data) {
logger.error({ error }, "Failed to fetch document stats from NOC");
return;
}
// biome-ignore lint/suspicious/noExplicitAny: External API response
const resData = (data as any).data;
if (!Array.isArray(resData)) {
logger.warn({ data }, "Document stats data from NOC is not an array");
return;
}
for (const stat of resData) {
await prisma.documentStat.upsert({
where: {
villageId_label: {
villageId: ID_DESA,
label: stat.label,
},
},
update: {
value: stat.value,
color: stat.color,
},
create: {
villageId: ID_DESA,
label: stat.label,
value: stat.value,
color: stat.color,
},
});
}
logger.info(`Synced ${resData.length} document stats`);
}
/**
* 6. Update lastSyncedAt timestamp
*/
async function syncLastTimestamp() {
logger.info("Updating sync timestamp...");
@@ -247,6 +291,7 @@ async function main() {
await syncLatestProjects();
await syncUpcomingEvents();
await syncLatestDiscussion();
await syncDocumentStats();
await syncLastTimestamp();
logger.info("NOC Data Synchronization Completed Successfully");

View File

@@ -1,6 +1,7 @@
import { Elysia, t } from "elysia";
import { prisma } from "../utils/db";
import { $ } from "bun";
import { nocExternalClient } from "../utils/noc-external-client";
export const noc = new Elysia({ prefix: "/noc" })
.post(
@@ -207,18 +208,63 @@ export const noc = new Elysia({ prefix: "/noc" })
"/diagram-jumlah-document",
async ({ query }) => {
const { idDesa } = query;
try {
// Coba tarik data dari NOC External API (sesuai permintaan user)
const { data: extData, error } = await nocExternalClient.GET(
"/api/noc/diagram-jumlah-document",
{
params: { query: { idDesa } },
},
);
if (!error && extData && (extData as any).success) {
return extData as any;
}
} catch (err) {
console.error("Failed to fetch document stats from NOC External", err);
}
// Fallback ke local database (tabel DocumentStat yang baru)
const stats = await prisma.documentStat.findMany({
where: { villageId: idDesa },
});
if (stats.length > 0) {
return {
success: true,
message: "Berhasil mendapatkan jumlah document dari database",
data: stats.map((s) => ({
label: s.label,
value: s.value,
color: s.color,
})),
};
}
// Fallback terakhir: groupBy Document (model lama)
const data = await prisma.document.groupBy({
where: { villageId: idDesa },
by: ["category"],
by: ["type"],
_count: {
_all: true,
},
});
const colorMap: Record<string, string> = {
Gambar: "#fac858",
Dokumen: "#92cc76",
PDF: "#3B82F6",
Excel: "#10B981",
};
return {
success: true,
message: "Berhasil mendapatkan jumlah document",
data: data.map((d) => ({
category: d.category,
count: d._count._all,
label: d.type,
value: d._count._all,
color: colorMap[d.type] || "#6B7280",
})),
};
},
@@ -228,10 +274,13 @@ export const noc = new Elysia({ prefix: "/noc" })
}),
response: {
200: t.Object({
success: t.Boolean(),
message: t.String(),
data: t.Array(
t.Object({
category: t.String(),
count: t.Number(),
label: t.String(),
value: t.Number(),
color: t.String(),
}),
),
}),

View File

@@ -19,8 +19,8 @@ import {
import { apiClient } from "@/utils/api-client";
interface DocumentData {
name: string;
jumlah: number;
label: string;
value: number;
color: string;
}
@@ -34,7 +34,13 @@ export function DocumentChart() {
useEffect(() => {
async function fetchDocumentStats() {
try {
const res = await apiClient.GET("/api/division/documents/stats");
const res = await apiClient.GET("/api/noc/diagram-jumlah-document", {
params: {
query: {
idDesa: "desa1",
},
},
});
if (res.data?.data) {
setData(res.data.data);
}
@@ -78,7 +84,7 @@ export function DocumentChart() {
stroke={dark ? "#334155" : "#e5e7eb"}
/>
<XAxis
dataKey="name"
dataKey="label"
axisLine={false}
tickLine={false}
tick={{ fill: dark ? "#E2E8F0" : "#374151" }}
@@ -97,9 +103,9 @@ export function DocumentChart() {
}}
labelStyle={{ color: dark ? "#E2E8F0" : "#374151" }}
/>
<Bar dataKey="jumlah" radius={[4, 4, 0, 0]}>
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{data.map((entry) => (
<Cell key={`cell-${entry.name}`} fill={entry.color} />
<Cell key={`cell-${entry.label}`} fill={entry.color} />
))}
</Bar>
</BarChart>