Compare commits
13 Commits
amalia/23-
...
join
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ce5e14a6c | |||
| 28a536ae17 | |||
| 48f73b627d | |||
| 6b4dd91e0b | |||
| f2793a7c70 | |||
| 177172fad0 | |||
| fa16c05cde | |||
| 705992df45 | |||
| 191e3624b8 | |||
| 242d8fa219 | |||
| 8528ed69b6 | |||
| a53568da8f | |||
| 92859fca6d |
43
.claude/ARCHITECTURE.md
Normal file
43
.claude/ARCHITECTURE.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Architecture
|
||||
|
||||
**Sistem Desa Mandiri** is a village administration platform built on Next.js 14 (App Router) with PostgreSQL.
|
||||
|
||||
## Key Layers
|
||||
|
||||
- **`src/app/(application)/`** — Auth-protected pages grouped by feature (announcement, division, project, discussion, member, profile, home, group)
|
||||
- **`src/app/(auth)/`** — Login/register pages
|
||||
- **`src/app/api/`** — REST API endpoints; subdirectories map to resource types (`/api/announcement`, `/api/project`, `/api/task`, etc.). Mobile-specific endpoints live under `/api/mobile/`
|
||||
- **`src/module/`** — Business logic modules, one per feature (19 modules). Each module contains hooks, components, and API call functions for that domain
|
||||
- **`src/lib/`** — Shared utilities: Prisma client singleton (`prisma.ts`), Firebase init, route definitions (`routes.ts`), push notification hooks
|
||||
|
||||
## Data Access
|
||||
|
||||
All DB access goes through the Prisma client singleton in `src/lib/prisma.ts`. Schema at `prisma/schema.prisma` (40+ models). Migrations in `prisma/migrations/`.
|
||||
|
||||
## State Management
|
||||
|
||||
- **Hookstate** (`@hookstate/core` + `@hookstate/localstored`) — client-side global state with localStorage persistence
|
||||
- **Iron-session** — server-side session management / auth
|
||||
- **Jose** — JWT handling
|
||||
|
||||
## UI Stack
|
||||
|
||||
- **Mantine 7** — primary UI library (components, forms, modals, notifications, charts, dates)
|
||||
- **Tailwind CSS** — utility classes, used alongside Mantine
|
||||
- **PostCSS** — configured with Mantine preset (`postcss.config.mjs`)
|
||||
|
||||
## Real-time & Notifications
|
||||
|
||||
- **Firebase FCM** (`src/lib/firebase/`) — mobile push notifications
|
||||
- **Web Push + VAPID keys** (`src/lib/usePushNotifications.ts`) — browser push
|
||||
- **wibu-realtime** (custom library) — WebSocket-based real-time updates
|
||||
|
||||
## User Roles
|
||||
|
||||
Five roles with distinct access levels (see `PANDUAN PENGGUNAAN.md`):
|
||||
|
||||
1. **Super Admin** — full system access
|
||||
2. **Admin Desa** — village-level administration
|
||||
3. **Ketua Divisi** — division leader
|
||||
4. **Anggota Divisi** — division member
|
||||
5. **Warga/Perangkat Desa** — village resident/official
|
||||
39
.claude/DEPLOYMENT.md
Normal file
39
.claude/DEPLOYMENT.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Deployment
|
||||
|
||||
Docker images are built via `.github/workflows/publish.yml` and pushed to GHCR (`ghcr.io`). Portainer redeploys via `.github/workflows/re-pull.yml`. Supports `dev`, `stg`, and `prod` stacks.
|
||||
|
||||
The Dockerfile uses a two-stage build: Bun builder → Bun runner (non-root user, port 3000).
|
||||
|
||||
## Git Remote Structure
|
||||
|
||||
| Remote | URL | Purpose |
|
||||
|--------|-----|---------|
|
||||
| `origin` | wibugit.wibudev.com/wibu/sistem-desa-mandiri | Repo kerja tim |
|
||||
| `build` | github.com/bipprojectbali/desa-plus | Repo deployment (trigger CI/CD) |
|
||||
|
||||
**Branch mapping:**
|
||||
- `origin/staging` — branch integrasi tim (bukan deployment target)
|
||||
- `build/stg` — branch deployment stg (trigger publish image + Portainer repull)
|
||||
- `build/prod` — branch deployment prod
|
||||
- `build/dev` — branch deployment dev
|
||||
|
||||
## Deploy to STG Flow
|
||||
|
||||
Cukup jalankan MCP `deploy-stg` — handles otomatis: cek migrasi → bump version → commit → push ke `build/stg` → trigger publish workflow (`ref: stg`) → tunggu selesai → trigger repull Portainer → verify version via `BASE_URL${VERSION_PATH}`.
|
||||
|
||||
> `origin` tidak punya branch `stg` (hanya `staging`). "stg" selalu merujuk ke `build/stg`.
|
||||
|
||||
## MCP `deploy-stg`
|
||||
|
||||
Lokasi: `.mcp/deploy-stg/server.ts`. Berkomunikasi langsung dengan GitHub REST API (tidak butuh `gh` CLI), hanya perlu `git` & `prisma` lokal.
|
||||
|
||||
**Env vars** (di `.mcp.json` atau `.env`):
|
||||
- `GH_TOKEN` — PAT dengan scope `repo` + `workflow` untuk trigger Actions
|
||||
- `GH_URL` — repo build target, format `owner/repo` atau full URL
|
||||
- `BASE_URL` — base URL stg untuk verifikasi versi
|
||||
- `VERSION_PATH` — endpoint cek versi (default `/api/version-app`)
|
||||
- `STACK_NAME` — nama stack Portainer
|
||||
|
||||
**Tools:** `deploy`, `publish`, `repull`, `run_status`, `check_version`.
|
||||
|
||||
**Penting:** workflow `publish.yml` & `re-pull.yml` di-trigger dengan `ref: stg` agar `actions/checkout@v4` checkout dari branch `stg`, bukan default branch (`main`).
|
||||
24
.claude/ENV.md
Normal file
24
.claude/ENV.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env`. Required variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `GOOGLE_PROJECT_ID`, `GOOGLE_CLIENT_EMAIL`, `GOOGLE_PRIVATE_KEY` | Firebase Admin SDK (FCM) |
|
||||
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | Web Push |
|
||||
| `WS_APIKEY` | WebSocket/file storage API key |
|
||||
| `WIBU_REALTIME_KEY` | Real-time communication |
|
||||
| `FCM_KEY` | Firebase Cloud Messaging |
|
||||
|
||||
## Deployment (MCP `deploy-stg`)
|
||||
|
||||
Diisi di `.env` lokal (jangan commit `GH_TOKEN`). `.mcp.json` me-reference via `${GH_TOKEN}`.
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `GH_TOKEN` | GitHub PAT dengan scope `repo` + `workflow` |
|
||||
| `GH_URL` | Repo build target (`owner/repo` atau full URL) |
|
||||
| `BASE_URL` | Base URL deployment stg (untuk verifikasi versi) |
|
||||
| `VERSION_PATH` | Endpoint cek versi (default `/api/version-app`) |
|
||||
| `STACK_NAME` | Nama stack di Portainer |
|
||||
@@ -42,6 +42,12 @@ VAPID_PRIVATE_KEY="UHDY8M3-0beVIA2kt2zL3ZeMStJ0j6zVkVd2Cfqpgrc"
|
||||
# API key for file operations (upload, delete, copy, view directory)
|
||||
WS_APIKEY="your-websocket-api-key"
|
||||
|
||||
# ===========================================
|
||||
# MONITORING API
|
||||
# ===========================================
|
||||
# API key untuk akses endpoint /api/monitoring (header: x-api-key)
|
||||
MONITORING_API_KEY="your-monitoring-api-key"
|
||||
|
||||
# ===========================================
|
||||
# APPLICATION SETTINGS
|
||||
# ===========================================
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"command": "bun",
|
||||
"args": ["run", ".mcp/deploy-stg/server.ts"],
|
||||
"env": {
|
||||
"GH_TOKEN": "${GH_TOKEN}",
|
||||
"GH_URL": "bipprojectbali/desa-plus",
|
||||
"BASE_URL": "https://desa-plus-stg.wibudev.com",
|
||||
"VERSION_PATH": "/api/version-app",
|
||||
"STACK_NAME": "desa-plus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,24 +4,127 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { execFileSync, execSync } from "child_process";
|
||||
import { execFileSync } from "child_process";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = path.resolve(__dirname, "../..");
|
||||
const REPO = "bipprojectbali/desa-plus";
|
||||
const STACK_ENV = "stg";
|
||||
const BASE_URL = process.env.BASE_URL ?? "";
|
||||
const VERSION_PATH = process.env.VERSION_PATH ?? "/api/version-app";
|
||||
const DEFAULT_STACK_NAME = process.env.STACK_NAME ?? "";
|
||||
const GH_TOKEN = process.env.GH_TOKEN ?? "";
|
||||
|
||||
const GH = (args: string[]) =>
|
||||
execFileSync("gh", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
|
||||
const GH_URL_RAW = process.env.GH_URL ?? "";
|
||||
// support both "owner/repo" and "https://github.com/owner/repo" formats
|
||||
const REPO = GH_URL_RAW.startsWith("http")
|
||||
? GH_URL_RAW.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "")
|
||||
: GH_URL_RAW;
|
||||
|
||||
const GIT = (args: string[]) =>
|
||||
execFileSync("git", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
|
||||
|
||||
// --- GitHub API client (no gh CLI) ---
|
||||
|
||||
const GH_API = "https://api.github.com";
|
||||
|
||||
type WorkflowRun = {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
conclusion: string | null;
|
||||
html_url: string;
|
||||
created_at: string;
|
||||
run_started_at: string;
|
||||
};
|
||||
|
||||
async function ghFetch<T = unknown>(
|
||||
pathname: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<T> {
|
||||
if (!GH_TOKEN) throw new Error("GH_TOKEN tidak di-set.");
|
||||
if (!REPO) throw new Error("GH_URL tidak di-set.");
|
||||
|
||||
const res = await fetch(`${GH_API}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${GH_TOKEN}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`GitHub API ${res.status} ${res.statusText}: ${body}`);
|
||||
}
|
||||
|
||||
// 204 No Content (e.g. workflow dispatch)
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function triggerWorkflow(
|
||||
workflow: string,
|
||||
ref: string,
|
||||
inputs: Record<string, string>
|
||||
): Promise<void> {
|
||||
await ghFetch(`/repos/${REPO}/actions/workflows/${workflow}/dispatches`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ref, inputs }),
|
||||
});
|
||||
}
|
||||
|
||||
async function getLatestRun(workflow: string): Promise<WorkflowRun> {
|
||||
const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>(
|
||||
`/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=1`
|
||||
);
|
||||
if (!data.workflow_runs?.length) {
|
||||
throw new Error(`Tidak ada run untuk workflow ${workflow}.`);
|
||||
}
|
||||
return data.workflow_runs[0];
|
||||
}
|
||||
|
||||
async function listRuns(
|
||||
workflow: string | "all",
|
||||
limit: number
|
||||
): Promise<WorkflowRun[]> {
|
||||
const url =
|
||||
workflow === "all"
|
||||
? `/repos/${REPO}/actions/runs?per_page=${limit}`
|
||||
: `/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=${limit}`;
|
||||
const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>(url);
|
||||
return data.workflow_runs ?? [];
|
||||
}
|
||||
|
||||
async function waitForRun(
|
||||
runId: number,
|
||||
timeoutMs = 30 * 60 * 1000
|
||||
): Promise<WorkflowRun> {
|
||||
const interval = 10_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const run = await ghFetch<WorkflowRun>(
|
||||
`/repos/${REPO}/actions/runs/${runId}`
|
||||
);
|
||||
if (run.status === "completed") {
|
||||
if (run.conclusion !== "success") {
|
||||
throw new Error(
|
||||
`Run ${runId} selesai dengan conclusion: ${run.conclusion}`
|
||||
);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
}
|
||||
throw new Error(`Timeout menunggu run ${runId}.`);
|
||||
}
|
||||
|
||||
// --- version helpers ---
|
||||
|
||||
function bumpVersion(version: string, type: "patch" | "minor" | "major"): string {
|
||||
@@ -48,7 +151,7 @@ function applyVersionBump(newVersion: string): void {
|
||||
async function waitForDeployedVersion(expected: string, timeoutMs = 5 * 60 * 1000): Promise<string> {
|
||||
if (!BASE_URL) return "BASE_URL tidak di-set, skip cek versi stg.";
|
||||
|
||||
const url = `${BASE_URL}/api/version-app`;
|
||||
const url = `${BASE_URL}${VERSION_PATH}`;
|
||||
const interval = 15_000;
|
||||
const maxAttempts = Math.ceil(timeoutMs / interval);
|
||||
let last = "";
|
||||
@@ -197,56 +300,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
|
||||
// 3. Push to build remote (GitHub)
|
||||
const currentBranch = GIT(["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
GIT(["push", "build", `${currentBranch}:main`, "--force"]);
|
||||
GIT(["push", "build", `${currentBranch}:stg`, "--force"]);
|
||||
|
||||
// 4. Trigger publish
|
||||
GH([
|
||||
"workflow", "run", "publish.yml",
|
||||
"--repo", REPO,
|
||||
"--field", `stack_env=${STACK_ENV}`,
|
||||
"--field", `tag=${newVersion}`,
|
||||
]);
|
||||
// 4. Trigger publish workflow
|
||||
await triggerWorkflow("publish.yml", STACK_ENV, {
|
||||
stack_env: STACK_ENV,
|
||||
tag: newVersion,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
|
||||
const publishRunId = GH([
|
||||
"run", "list", "--repo", REPO,
|
||||
"--workflow", "publish.yml",
|
||||
"--limit", "1",
|
||||
"--json", "databaseId",
|
||||
"--jq", ".[0].databaseId",
|
||||
]);
|
||||
const publishUrl = GH([
|
||||
"run", "list", "--repo", REPO,
|
||||
"--workflow", "publish.yml",
|
||||
"--limit", "1",
|
||||
"--json", "url",
|
||||
"--jq", ".[0].url",
|
||||
]);
|
||||
const publishRun = await getLatestRun("publish.yml");
|
||||
|
||||
// 5. Wait for publish to finish
|
||||
execSync(`gh run watch ${publishRunId} --repo ${REPO} --exit-status`, {
|
||||
encoding: "utf-8",
|
||||
cwd: PROJECT_ROOT,
|
||||
timeout: 30 * 60 * 1000,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await waitForRun(publishRun.id);
|
||||
|
||||
// 6. Trigger repull
|
||||
GH([
|
||||
"workflow", "run", "re-pull.yml",
|
||||
"--repo", REPO,
|
||||
"--field", `stack_name=${stack_name}`,
|
||||
"--field", `stack_env=${STACK_ENV}`,
|
||||
]);
|
||||
await triggerWorkflow("re-pull.yml", STACK_ENV, {
|
||||
stack_name,
|
||||
stack_env: STACK_ENV,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
|
||||
const repullUrl = GH([
|
||||
"run", "list", "--repo", REPO,
|
||||
"--workflow", "re-pull.yml",
|
||||
"--limit", "1",
|
||||
"--json", "url",
|
||||
"--jq", ".[0].url",
|
||||
]);
|
||||
const repullRun = await getLatestRun("re-pull.yml");
|
||||
|
||||
// 7. Wait for repull, then verify version
|
||||
await new Promise((r) => setTimeout(r, 30_000));
|
||||
@@ -260,8 +335,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
type: "text",
|
||||
text: [
|
||||
`Deploy selesai: ${stack_name}-${STACK_ENV} @ ${newVersion} (dari ${oldVersion})`,
|
||||
`Publish run : ${publishUrl}`,
|
||||
`Repull run : ${repullUrl}`,
|
||||
`Publish run : ${publishRun.html_url}`,
|
||||
`Repull run : ${repullRun.html_url}`,
|
||||
``,
|
||||
`Versi lokal : ${localVer}`,
|
||||
versionCheck,
|
||||
@@ -275,24 +350,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (name === "publish") {
|
||||
const tag = readPkgVersion();
|
||||
|
||||
GH([
|
||||
"workflow", "run", "publish.yml",
|
||||
"--repo", REPO,
|
||||
"--field", `stack_env=${STACK_ENV}`,
|
||||
"--field", `tag=${tag}`,
|
||||
]);
|
||||
await triggerWorkflow("publish.yml", STACK_ENV, {
|
||||
stack_env: STACK_ENV,
|
||||
tag,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
const runUrl = GH([
|
||||
"run", "list", "--repo", REPO,
|
||||
"--workflow", "publish.yml",
|
||||
"--limit", "1",
|
||||
"--json", "url",
|
||||
"--jq", ".[0].url",
|
||||
]);
|
||||
const run = await getLatestRun("publish.yml");
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${runUrl}` }],
|
||||
content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${run.html_url}` }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -302,24 +369,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const stack_name = _sn || DEFAULT_STACK_NAME;
|
||||
if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong.");
|
||||
|
||||
GH([
|
||||
"workflow", "run", "re-pull.yml",
|
||||
"--repo", REPO,
|
||||
"--field", `stack_name=${stack_name}`,
|
||||
"--field", `stack_env=${STACK_ENV}`,
|
||||
]);
|
||||
await triggerWorkflow("re-pull.yml", STACK_ENV, {
|
||||
stack_name,
|
||||
stack_env: STACK_ENV,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
const runUrl = GH([
|
||||
"run", "list", "--repo", REPO,
|
||||
"--workflow", "re-pull.yml",
|
||||
"--limit", "1",
|
||||
"--json", "url",
|
||||
"--jq", ".[0].url",
|
||||
]);
|
||||
const run = await getLatestRun("re-pull.yml");
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${runUrl}` }],
|
||||
content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${run.html_url}` }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -329,17 +388,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
workflow?: string;
|
||||
limit?: number;
|
||||
};
|
||||
const workflowArgs = workflow === "all" ? [] : ["--workflow", workflow];
|
||||
|
||||
const output = GH([
|
||||
"run", "list",
|
||||
"--repo", REPO,
|
||||
...workflowArgs,
|
||||
"--limit", String(limit),
|
||||
"--json", "workflowName,status,conclusion,startedAt,url,databaseId",
|
||||
"--jq",
|
||||
'.[] | "[\(.status)/\(.conclusion // "-")] \(.workflowName) — \(.startedAt)\n \(.url)"',
|
||||
]);
|
||||
const runs = await listRuns(workflow, limit);
|
||||
const output = runs
|
||||
.map(
|
||||
(r) =>
|
||||
`[${r.status}/${r.conclusion ?? "-"}] ${r.name} — ${r.run_started_at}\n ${r.html_url}`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: output || "Tidak ada run ditemukan." }],
|
||||
@@ -353,7 +409,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
|
||||
if (BASE_URL) {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/version-app`);
|
||||
const res = await fetch(`${BASE_URL}${VERSION_PATH}`);
|
||||
const data = (await res.json()) as { version?: string };
|
||||
stgVersion = data.version ?? "?";
|
||||
} catch (e) {
|
||||
@@ -371,7 +427,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
type: "text",
|
||||
text: [
|
||||
`Lokal (package.json) : ${localVersion}`,
|
||||
`Stg (/api/version-app): ${stgVersion}`,
|
||||
`Stg (${VERSION_PATH}): ${stgVersion}`,
|
||||
`Status : ${match}`,
|
||||
].join("\n"),
|
||||
},
|
||||
|
||||
56
CLAUDE.md
56
CLAUDE.md
@@ -20,62 +20,12 @@ npx prisma generate # Regenerate Prisma client after schema changes
|
||||
|
||||
## Architecture
|
||||
|
||||
**Sistem Desa Mandiri** is a village administration platform built on Next.js 14 (App Router) with PostgreSQL.
|
||||
|
||||
### Key Layers
|
||||
|
||||
- **`src/app/(application)/`** — Auth-protected pages grouped by feature (announcement, division, project, discussion, member, profile, home, group)
|
||||
- **`src/app/(auth)/`** — Login/register pages
|
||||
- **`src/app/api/`** — REST API endpoints; subdirectories map to resource types (`/api/announcement`, `/api/project`, `/api/task`, etc.). Mobile-specific endpoints live under `/api/mobile/`
|
||||
- **`src/module/`** — Business logic modules, one per feature (19 modules). Each module contains hooks, components, and API call functions for that domain
|
||||
- **`src/lib/`** — Shared utilities: Prisma client singleton (`prisma.ts`), Firebase init, route definitions (`routes.ts`), push notification hooks
|
||||
|
||||
### Data Access Pattern
|
||||
|
||||
All DB access goes through the Prisma client singleton in `src/lib/prisma.ts`. Prisma schema is at `prisma/schema.prisma` (40+ models). Migrations live in `prisma/migrations/`.
|
||||
|
||||
### State Management
|
||||
|
||||
- **Hookstate** (`@hookstate/core` + `@hookstate/localstored`) for client-side global state with localStorage persistence
|
||||
- **Iron-session** for server-side session management / auth
|
||||
- **Jose** for JWT handling
|
||||
|
||||
### UI Stack
|
||||
|
||||
- **Mantine 7** is the primary UI library (components, forms, modals, notifications, charts, dates, etc.)
|
||||
- **Tailwind CSS** for utility classes — used alongside Mantine
|
||||
- **PostCSS** configured with Mantine preset (`postcss.config.mjs`)
|
||||
|
||||
### Real-time & Notifications
|
||||
|
||||
- **Firebase FCM** (`src/lib/firebase/`) for mobile push notifications
|
||||
- **Web Push + VAPID keys** (`src/lib/usePushNotifications.ts`) for browser push
|
||||
- **wibu-realtime** (custom library) for WebSocket-based real-time updates
|
||||
|
||||
### User Roles
|
||||
|
||||
Five roles with distinct access levels (see `PANDUAN PENGGUNAAN.md`):
|
||||
1. **Super Admin** — full system access
|
||||
2. **Admin Desa** — village-level administration
|
||||
3. **Ketua Divisi** — division leader
|
||||
4. **Anggota Divisi** — division member
|
||||
5. **Warga/Perangkat Desa** — village resident/official
|
||||
See @.claude/ARCHITECTURE.md
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env`. Required variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `GOOGLE_PROJECT_ID`, `GOOGLE_CLIENT_EMAIL`, `GOOGLE_PRIVATE_KEY` | Firebase Admin SDK (FCM) |
|
||||
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | Web Push |
|
||||
| `WS_APIKEY` | WebSocket/file storage API key |
|
||||
| `WIBU_REALTIME_KEY` | Real-time communication |
|
||||
| `FCM_KEY` | Firebase Cloud Messaging |
|
||||
See @.claude/ENV.md
|
||||
|
||||
## Deployment
|
||||
|
||||
Docker images are built via `.github/workflows/publish.yml` and pushed to GHCR (`ghcr.io`). Portainer redeploys via `.github/workflows/re-pull.yml`. Supports `dev`, `stg`, and `prod` stacks.
|
||||
|
||||
The Dockerfile uses a two-stage build: Bun builder → Bun runner (non-root user, port 3000).
|
||||
See @.claude/DEPLOYMENT.md
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "sistem-desa-mandiri",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --experimental-https",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"claude": "set -a && source .env && set +a && claude"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "npx tsx prisma/seed.ts"
|
||||
|
||||
1
prisma/migrations/20260430070147_auto/migration.sql
Normal file
1
prisma/migrations/20260430070147_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
1
prisma/migrations/20260504064301_auto/migration.sql
Normal file
1
prisma/migrations/20260504064301_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
1
prisma/migrations/20260504064957_auto/migration.sql
Normal file
1
prisma/migrations/20260504064957_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
1
prisma/migrations/20260504074029_auto/migration.sql
Normal file
1
prisma/migrations/20260504074029_auto/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
@@ -25,6 +25,18 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
|
||||
}
|
||||
}
|
||||
}))
|
||||
.onBeforeHandle(({ request, set, path }) => {
|
||||
// Docs tidak perlu API key
|
||||
if (path.startsWith("/api/monitoring/docs")) return;
|
||||
|
||||
const apiKey = process.env.MONITORING_API_KEY;
|
||||
const incoming = request.headers.get("x-api-key");
|
||||
|
||||
if (!apiKey || incoming !== apiKey) {
|
||||
set.status = 401;
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
})
|
||||
|
||||
.get("/grid-overview", async ({ query, set }) => {
|
||||
try {
|
||||
@@ -1503,7 +1515,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" })
|
||||
idUserRole: t.String({ description: "ID Role" }),
|
||||
idVillage: t.String({ description: "ID Desa" }),
|
||||
idGroup: t.String({ description: "ID Group" }),
|
||||
idPosition: t.Optional(t.String({ description: "ID Posisi" })),
|
||||
idPosition: t.Optional(t.Union([t.String(), t.Null()], { description: "ID Posisi" })),
|
||||
isActive: t.Boolean({ description: "Aktif" }),
|
||||
isWithoutOTP: t.Boolean({ description: "Tanpa OTP" }),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user