feat(noc): implement NOC API module and sync strategy task

This commit is contained in:
2026-03-30 14:32:12 +08:00
parent ed93363de1
commit 3125bc1002
7 changed files with 1605 additions and 0 deletions

View File

@@ -17,3 +17,4 @@ LOG_LEVEL=info
# Public URL
VITE_PUBLIC_URL="http://localhost:3000"
NOC_API_URL="https://darmasaba.muku.id/api/noc/docs/json"

68
__tests__/api/noc.test.ts Normal file
View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "bun:test";
import api from "@/api";
describe("NOC API Module", () => {
const idDesa = "darmasaba";
it("should return active divisions", async () => {
const response = await api.handle(
new Request(`http://localhost/api/noc/active-divisions?idDesa=${idDesa}`),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return latest projects", async () => {
const response = await api.handle(
new Request(`http://localhost/api/noc/latest-projects?idDesa=${idDesa}`),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return upcoming events", async () => {
const response = await api.handle(
new Request(`http://localhost/api/noc/upcoming-events?idDesa=${idDesa}`),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return diagram jumlah document", async () => {
const response = await api.handle(
new Request(`http://localhost/api/noc/diagram-jumlah-document?idDesa=${idDesa}`),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return diagram progres kegiatan", async () => {
const response = await api.handle(
new Request(`http://localhost/api/noc/diagram-progres-kegiatan?idDesa=${idDesa}`),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return latest discussion", async () => {
const response = await api.handle(
new Request(`http://localhost/api/noc/latest-discussion?idDesa=${idDesa}`),
);
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data.data)).toBe(true);
});
it("should return 400 for missing idDesa in active-divisions", async () => {
const response = await api.handle(
new Request("http://localhost/api/noc/active-divisions"),
);
// Elysia returns 400 or 422 for validation errors
expect([400, 422]).toContain(response.status);
});
});

View File

@@ -457,6 +457,102 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/noc/active-divisions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocActive-divisions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/latest-projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocLatest-projects"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/upcoming-events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocUpcoming-events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/diagram-jumlah-document": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocDiagram-jumlah-document"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/diagram-progres-kegiatan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocDiagram-progres-kegiatan"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/noc/latest-discussion": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getApiNocLatest-discussion"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -1974,4 +2070,279 @@ export interface operations {
};
};
};
"getApiNocActive-divisions": {
parameters: {
query: {
idDesa: string;
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
name: string;
activityCount: number;
color: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
name: string;
activityCount: number;
color: string;
}[];
};
"text/plain": {
data: {
id: string;
name: string;
activityCount: number;
color: string;
}[];
};
};
};
};
};
"getApiNocLatest-projects": {
parameters: {
query: {
idDesa: string;
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
title: string;
status: string;
progress: number;
divisionName: string;
createdAt: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
title: string;
status: string;
progress: number;
divisionName: string;
createdAt: string;
}[];
};
"text/plain": {
data: {
id: string;
title: string;
status: string;
progress: number;
divisionName: string;
createdAt: string;
}[];
};
};
};
};
};
"getApiNocUpcoming-events": {
parameters: {
query: {
idDesa: string;
limit?: string;
filter?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
title: string;
startDate: string;
location: (string | null) | null;
eventType: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
title: string;
startDate: string;
location: (string | null) | null;
eventType: string;
}[];
};
"text/plain": {
data: {
id: string;
title: string;
startDate: string;
location: (string | null) | null;
eventType: string;
}[];
};
};
};
};
};
"getApiNocDiagram-jumlah-document": {
parameters: {
query: {
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
category: string;
count: number;
}[];
};
"multipart/form-data": {
data: {
category: string;
count: number;
}[];
};
"text/plain": {
data: {
category: string;
count: number;
}[];
};
};
};
};
};
"getApiNocDiagram-progres-kegiatan": {
parameters: {
query: {
idDesa: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
status: string;
avgProgress: number;
count: number;
}[];
};
"multipart/form-data": {
data: {
status: string;
avgProgress: number;
count: number;
}[];
};
"text/plain": {
data: {
status: string;
avgProgress: number;
count: number;
}[];
};
};
};
};
};
"getApiNocLatest-discussion": {
parameters: {
query: {
idDesa: string;
limit?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
message: string;
senderName: string;
senderImage: (string | null) | null;
divisionName: string;
createdAt: string;
}[];
};
"multipart/form-data": {
data: {
id: string;
message: string;
senderName: string;
senderImage: (string | null) | null;
divisionName: string;
createdAt: string;
}[];
};
"text/plain": {
data: {
id: string;
message: string;
senderName: string;
senderImage: (string | null) | null;
divisionName: string;
createdAt: string;
}[];
};
};
};
};
};
}

View File

@@ -4022,6 +4022,892 @@
},
"operationId": "getApiDashboardSatisfaction"
}
},
"/api/noc/active-divisions": {
"get": {
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "idDesa",
"required": true
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "limit",
"required": false
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"name",
"activityCount",
"color"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"activityCount": {
"type": "number"
},
"color": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"name",
"activityCount",
"color"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"activityCount": {
"type": "number"
},
"color": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
},
"text/plain": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"name",
"activityCount",
"color"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"activityCount": {
"type": "number"
},
"color": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
}
}
}
},
"operationId": "getApiNocActive-divisions"
}
},
"/api/noc/latest-projects": {
"get": {
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "idDesa",
"required": true
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "limit",
"required": false
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"title",
"status",
"progress",
"divisionName",
"createdAt"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"status": {
"type": "string"
},
"progress": {
"type": "number"
},
"divisionName": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"title",
"status",
"progress",
"divisionName",
"createdAt"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"status": {
"type": "string"
},
"progress": {
"type": "number"
},
"divisionName": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
},
"text/plain": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"title",
"status",
"progress",
"divisionName",
"createdAt"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"status": {
"type": "string"
},
"progress": {
"type": "number"
},
"divisionName": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
}
}
}
},
"operationId": "getApiNocLatest-projects"
}
},
"/api/noc/upcoming-events": {
"get": {
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "idDesa",
"required": true
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "limit",
"required": false
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "filter",
"required": false
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"title",
"startDate",
"location",
"eventType"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"startDate": {
"type": "string"
},
"location": {
"nullable": true,
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"eventType": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"title",
"startDate",
"location",
"eventType"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"startDate": {
"type": "string"
},
"location": {
"nullable": true,
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"eventType": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
},
"text/plain": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"title",
"startDate",
"location",
"eventType"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"startDate": {
"type": "string"
},
"location": {
"nullable": true,
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"eventType": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
}
}
}
},
"operationId": "getApiNocUpcoming-events"
}
},
"/api/noc/diagram-jumlah-document": {
"get": {
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "idDesa",
"required": true
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"category",
"count"
],
"properties": {
"category": {
"type": "string"
},
"count": {
"type": "number"
}
}
}
}
},
"required": [
"data"
]
}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"category",
"count"
],
"properties": {
"category": {
"type": "string"
},
"count": {
"type": "number"
}
}
}
}
},
"required": [
"data"
]
}
},
"text/plain": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"category",
"count"
],
"properties": {
"category": {
"type": "string"
},
"count": {
"type": "number"
}
}
}
}
},
"required": [
"data"
]
}
}
}
}
},
"operationId": "getApiNocDiagram-jumlah-document"
}
},
"/api/noc/diagram-progres-kegiatan": {
"get": {
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "idDesa",
"required": true
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"status",
"avgProgress",
"count"
],
"properties": {
"status": {
"type": "string"
},
"avgProgress": {
"type": "number"
},
"count": {
"type": "number"
}
}
}
}
},
"required": [
"data"
]
}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"status",
"avgProgress",
"count"
],
"properties": {
"status": {
"type": "string"
},
"avgProgress": {
"type": "number"
},
"count": {
"type": "number"
}
}
}
}
},
"required": [
"data"
]
}
},
"text/plain": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"status",
"avgProgress",
"count"
],
"properties": {
"status": {
"type": "string"
},
"avgProgress": {
"type": "number"
},
"count": {
"type": "number"
}
}
}
}
},
"required": [
"data"
]
}
}
}
}
},
"operationId": "getApiNocDiagram-progres-kegiatan"
}
},
"/api/noc/latest-discussion": {
"get": {
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "idDesa",
"required": true
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "limit",
"required": false
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"message",
"senderName",
"senderImage",
"divisionName",
"createdAt"
],
"properties": {
"id": {
"type": "string"
},
"message": {
"type": "string"
},
"senderName": {
"type": "string"
},
"senderImage": {
"nullable": true,
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"divisionName": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"message",
"senderName",
"senderImage",
"divisionName",
"createdAt"
],
"properties": {
"id": {
"type": "string"
},
"message": {
"type": "string"
},
"senderName": {
"type": "string"
},
"senderImage": {
"nullable": true,
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"divisionName": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
},
"text/plain": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"message",
"senderName",
"senderImage",
"divisionName",
"createdAt"
],
"properties": {
"id": {
"type": "string"
},
"message": {
"type": "string"
},
"senderName": {
"type": "string"
},
"senderImage": {
"nullable": true,
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"divisionName": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
}
}
},
"required": [
"data"
]
}
}
}
}
},
"operationId": "getApiNocLatest-discussion"
}
}
},
"components": {

View File

@@ -10,6 +10,7 @@ import { division } from "./division";
import { event } from "./event";
import { profile } from "./profile";
import { resident } from "./resident";
import { noc } from "./noc";
const isProduction = process.env.NODE_ENV === "production";
@@ -35,6 +36,7 @@ const api = new Elysia({
},
},
)
.use(noc)
.use(apiMiddleware)
.use(apikey)
.use(profile)

276
src/api/noc.ts Normal file
View File

@@ -0,0 +1,276 @@
import { Elysia, t } from "elysia";
import { prisma } from "../utils/db";
export const noc = new Elysia({ prefix: "/noc" })
.get(
"/active-divisions",
async ({ query }) => {
const { idDesa, limit } = query;
// TODO: Filter by idDesa once schema supports it
const data = await prisma.division.findMany({
include: {
_count: {
select: { activities: true },
},
},
orderBy: {
activities: {
_count: "desc",
},
},
take: limit ? Number.parseInt(limit) : 5,
});
return {
data: data.map((d) => ({
id: d.id,
name: d.name,
activityCount: d._count.activities,
color: d.color,
})),
};
},
{
query: t.Object({
idDesa: t.String(),
limit: t.Optional(t.String()),
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
id: t.String(),
name: t.String(),
activityCount: t.Number(),
color: t.String(),
}),
),
}),
},
},
)
.get(
"/latest-projects",
async ({ query }) => {
const { idDesa, limit } = query;
// TODO: Filter by idDesa once schema supports it
const data = await prisma.activity.findMany({
orderBy: { createdAt: "desc" },
take: limit ? Number.parseInt(limit) : 5,
include: { division: true },
});
return {
data: data.map((a) => ({
id: a.id,
title: a.title,
status: a.status,
progress: a.progress,
divisionName: a.division.name,
createdAt: a.createdAt.toISOString(),
})),
};
},
{
query: t.Object({
idDesa: t.String(),
limit: t.Optional(t.String()),
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
id: t.String(),
title: t.String(),
status: t.String(),
progress: t.Number(),
divisionName: t.String(),
createdAt: t.String(),
}),
),
}),
},
},
)
.get(
"/upcoming-events",
async ({ query }) => {
const { idDesa, limit, filter } = query;
// TODO: Filter by idDesa once schema supports it
const now = new Date();
const where: any = {};
if (filter === "today") {
const startOfDay = new Date(now.setHours(0, 0, 0, 0));
const endOfDay = new Date(now.setHours(23, 59, 59, 999));
where.startDate = {
gte: startOfDay,
lte: endOfDay,
};
} else {
where.startDate = {
gte: now,
};
}
const data = await prisma.event.findMany({
where,
orderBy: { startDate: "asc" },
take: limit ? Number.parseInt(limit) : 5,
});
return {
data: data.map((e) => ({
id: e.id,
title: e.title,
startDate: e.startDate.toISOString(),
location: e.location,
eventType: e.eventType,
})),
};
},
{
query: t.Object({
idDesa: t.String(),
limit: t.Optional(t.String()),
filter: t.Optional(t.String()), // today/upcoming
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
id: t.String(),
title: t.String(),
startDate: t.String(),
location: t.Nullable(t.String()),
eventType: t.String(),
}),
),
}),
},
},
)
.get(
"/diagram-jumlah-document",
async ({ query }) => {
const { idDesa } = query;
// TODO: Filter by idDesa once schema supports it
const data = await prisma.document.groupBy({
by: ["category"],
_count: {
_all: true,
},
});
return {
data: data.map((d) => ({
category: d.category,
count: d._count._all,
})),
};
},
{
query: t.Object({
idDesa: t.String(),
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
category: t.String(),
count: t.Number(),
}),
),
}),
},
},
)
.get(
"/diagram-progres-kegiatan",
async ({ query }) => {
const { idDesa } = query;
// TODO: Filter by idDesa once schema supports it
const data = await prisma.activity.groupBy({
by: ["status"],
_avg: {
progress: true,
},
_count: {
_all: true,
},
});
return {
data: data.map((d) => ({
status: d.status,
avgProgress: d._avg.progress || 0,
count: d._count._all,
})),
};
},
{
query: t.Object({
idDesa: t.String(),
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
status: t.String(),
avgProgress: t.Number(),
count: t.Number(),
}),
),
}),
},
},
)
.get(
"/latest-discussion",
async ({ query }) => {
const { idDesa, limit } = query;
// TODO: Filter by idDesa once schema supports it
const data = await prisma.discussion.findMany({
orderBy: { createdAt: "desc" },
take: limit ? Number.parseInt(limit) : 5,
include: {
sender: {
select: { name: true, image: true },
},
division: {
select: { name: true },
},
},
});
return {
data: data.map((d) => ({
id: d.id,
message: d.message,
senderName: d.sender.name || "Anonymous",
senderImage: d.sender.image,
divisionName: d.division?.name || "General",
createdAt: d.createdAt.toISOString(),
})),
};
},
{
query: t.Object({
idDesa: t.String(),
limit: t.Optional(t.String()),
}),
response: {
200: t.Object({
data: t.Array(
t.Object({
id: t.String(),
message: t.String(),
senderName: t.String(),
senderImage: t.Nullable(t.String()),
divisionName: t.String(),
createdAt: t.String(),
}),
),
}),
},
});

View File

@@ -45,6 +45,7 @@ export function ActivityCard({
backgroundColor: dark ? "#334155" : "white",
overflow: "hidden",
}}
h={"100%"}
>
{/* 🔵 HEADER */}
<Box