Merge branch 'amalia/30-apr-26' into staging

This commit is contained in:
2026-05-04 14:35:11 +08:00
36 changed files with 4441 additions and 62 deletions

43
.claude/ARCHITECTURE.md Normal file
View 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

5
.claude/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,5 @@
# 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).

12
.claude/ENV.md Normal file
View File

@@ -0,0 +1,12 @@
# 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 |

58
.env.example Normal file
View File

@@ -0,0 +1,58 @@
# ===========================================
# SISTEM DESA MANDIRI - ENVIRONMENT VARIABLES
# ===========================================
# Copy this file to .env and fill in the appropriate values
# ===========================================
# DATABASE CONFIGURATION
# ===========================================
# PostgreSQL, MySQL, or SQLite connection string
# Example (PostgreSQL): postgresql://user:password@localhost:5432/dbname
# Example (MySQL): mysql://user:password@localhost:3306/dbname
# Example (SQLite): file:./dev.db
DATABASE_URL="your-database-url-here"
# ===========================================
# FIREBASE ADMIN SDK (For FCM Push Notifications)
# ===========================================
# Google Cloud project ID
GOOGLE_PROJECT_ID="your-google-project-id"
# Google service account client email
GOOGLE_CLIENT_EMAIL="your-service-account-email@your-project.iam.gserviceaccount.com"
# Google service account private key (include the full key with newlines)
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----"
# Google service account private key ID (optional but recommended)
GOOGLE_PRIVATE_KEY_ID="your-private-key-id"
# ===========================================
# WEB PUSH NOTIFICATIONS (VAPID Keys)
# ===========================================
# VAPID public key (exposed to client-side, must start with NEXT_PUBLIC_)
NEXT_PUBLIC_VAPID_PUBLIC_KEY="BJlglqrIZCbPCZyUs8UIzEP1Wi18hzvGaC3-KPLkQuoCV_EOKdyGJNbu7fs5jYaO571ipVAMko8YiwIMa1VjQEg"
# VAPID private key (keep secret, server-side only)
VAPID_PRIVATE_KEY="UHDY8M3-0beVIA2kt2zL3ZeMStJ0j6zVkVd2Cfqpgrc"
# ===========================================
# FILE STORAGE / WEBSOCKET API
# ===========================================
# 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
# ===========================================
# Next.js node environment (development, production, test)
NODE_ENV="development"
# Application URL (optional, for reference)
NEXT_PUBLIC_APP_URL="http://localhost:3000"

76
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Publish Docker to GHCR
on:
workflow_dispatch:
inputs:
stack_env:
description: "stack env"
required: true
type: choice
default: "dev"
options:
- dev
- prod
- stg
tag:
description: "Image tag (e.g. 1.0.0)"
required: true
default: "1.0.0"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
publish:
name: Build & Push to GHCR ${{ github.repository }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
df -h
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}
type=raw,value=${{ github.event.inputs.stack_env }}-latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true

37
.github/workflows/re-pull.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Re-Pull Docker
on:
workflow_dispatch:
inputs:
stack_name:
description: "stack name"
required: true
type: string
stack_env:
description: "stack env"
required: true
type: choice
default: "dev"
options:
- dev
- stg
- prod
jobs:
publish:
name: Re-Pull Docker ${{ github.event.inputs.stack_name }}
runs-on: ubuntu-latest
environment: ${{ vars.PORTAINER_ENV || 'portainer' }}
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Deploy ke Portainer
run: bash ./.github/workflows/script/re-pull.sh
env:
PORTAINER_USERNAME: ${{ secrets.PORTAINER_USERNAME }}
PORTAINER_PASSWORD: ${{ secrets.PORTAINER_PASSWORD }}
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
STACK_NAME: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}

97
.github/workflows/script/re-pull.sh vendored Normal file
View File

@@ -0,0 +1,97 @@
#!/bin/bash
: "${PORTAINER_URL:?PORTAINER_URL tidak di-set}"
: "${PORTAINER_USERNAME:?PORTAINER_USERNAME tidak di-set}"
: "${PORTAINER_PASSWORD:?PORTAINER_PASSWORD tidak di-set}"
: "${STACK_NAME:?STACK_NAME tidak di-set}"
echo "🔐 Autentikasi ke Portainer..."
TOKEN=$(curl -s -X POST https://${PORTAINER_URL}/api/auth \
-H "Content-Type: application/json" \
-d "{\"username\": \"${PORTAINER_USERNAME}\", \"password\": \"${PORTAINER_PASSWORD}\"}" \
| jq -r .jwt)
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "❌ Autentikasi gagal! Cek PORTAINER_URL, USERNAME, dan PASSWORD."
exit 1
fi
echo "🔍 Mencari stack: $STACK_NAME..."
STACK=$(curl -s -X GET https://${PORTAINER_URL}/api/stacks \
-H "Authorization: Bearer ${TOKEN}" \
| jq ".[] | select(.Name == \"$STACK_NAME\")")
if [ -z "$STACK" ]; then
echo "❌ Stack '$STACK_NAME' tidak ditemukan di Portainer!"
echo " Pastikan nama stack sudah benar."
exit 1
fi
STACK_ID=$(echo "$STACK" | jq -r .Id)
ENDPOINT_ID=$(echo "$STACK" | jq -r .EndpointId)
ENV=$(echo "$STACK" | jq '.Env // []')
echo "📄 Mengambil compose file..."
STACK_FILE=$(curl -s -X GET "https://${PORTAINER_URL}/api/stacks/${STACK_ID}/file" \
-H "Authorization: Bearer ${TOKEN}" \
| jq -r .StackFileContent)
PAYLOAD=$(jq -n \
--arg content "$STACK_FILE" \
--argjson env "$ENV" \
'{stackFileContent: $content, env: $env, pullImage: true}')
echo "🚀 Redeploying $STACK_NAME (pull latest image)..."
HTTP_STATUS=$(curl -s -o /tmp/portainer_response.json -w "%{http_code}" \
-X PUT "https://${PORTAINER_URL}/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
if [ "$HTTP_STATUS" = "524" ] || [ "$HTTP_STATUS" = "504" ] || [ "$HTTP_STATUS" = "408" ]; then
echo "⚠️ HTTP $HTTP_STATUS (gateway timeout) — Portainer tetap memproses redeploy, lanjut polling container..."
MAX_RETRY=60
elif [ "$HTTP_STATUS" != "200" ]; then
echo "❌ Redeploy gagal! HTTP Status: $HTTP_STATUS"
cat /tmp/portainer_response.json | jq . 2>/dev/null || true
exit 1
else
MAX_RETRY=30
fi
echo "⏳ Menunggu container running (max $((MAX_RETRY * 10))s)..."
COUNT=0
while [ $COUNT -lt $MAX_RETRY ]; do
sleep 10
COUNT=$((COUNT + 1))
CONTAINERS=$(curl -s --max-time 10 -X GET \
"https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true&filters=%7B%22label%22%3A%5B%22com.docker.compose.project%3D${STACK_NAME}%22%5D%7D" \
-H "Authorization: Bearer ${TOKEN}")
TOTAL=$(echo "$CONTAINERS" | jq 'length')
RUNNING=$(echo "$CONTAINERS" | jq '[.[] | select(.State == "running")] | length')
FAILED=$(echo "$CONTAINERS" | jq '[.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not))] | length')
echo "🔄 [${COUNT}/${MAX_RETRY}] Running: ${RUNNING} | Failed: ${FAILED} | Total: ${TOTAL}"
echo "$CONTAINERS" | jq -r '.[] | " → \(.Names[0]) | \(.State) | \(.Status)"'
if [ "$FAILED" -gt "0" ]; then
echo ""
echo "❌ Ada container yang crash!"
echo "$CONTAINERS" | jq -r '.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not)) | " → \(.Names[0]) | \(.Status)"'
exit 1
fi
if [ "$RUNNING" -gt "0" ]; then
echo ""
echo "✅ Stack $STACK_NAME berhasil di-redeploy dan running!"
exit 0
fi
done
echo ""
echo "❌ Timeout! Stack tidak kunjung running setelah $((MAX_RETRY * 10)) detik."
exit 1

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
# dependencies # dependencies
/node_modules /node_modules
.mcp/deploy-stg/node_modules
/.pnp /.pnp
.pnp.js .pnp.js
.yarn/install-state.gz .yarn/install-state.gz

13
.mcp.json Normal file
View File

@@ -0,0 +1,13 @@
{
"mcpServers": {
"deploy-stg": {
"type": "stdio",
"command": "bun",
"args": ["run", ".mcp/deploy-stg/server.ts"],
"env": {
"BASE_URL": "https://desa-plus-stg.wibudev.com",
"STACK_NAME": "desa-plus"
}
}
}
}

194
.mcp/deploy-stg/bun.lock Normal file
View File

@@ -0,0 +1,194 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "deploy-stg",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
},
},
},
"packages": {
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.4.0", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
}
}

View File

@@ -0,0 +1,9 @@
{
"name": "deploy-stg",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0"
}
}

393
.mcp/deploy-stg/server.ts Normal file
View File

@@ -0,0 +1,393 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { execFileSync, execSync } 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 DEFAULT_STACK_NAME = process.env.STACK_NAME ?? "";
const GH = (args: string[]) =>
execFileSync("gh", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
const GIT = (args: string[]) =>
execFileSync("git", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
// --- version helpers ---
function bumpVersion(version: string, type: "patch" | "minor" | "major"): string {
const [maj, min, pat] = version.split(".").map(Number);
if (type === "major") return `${maj + 1}.0.0`;
if (type === "minor") return `${maj}.${min + 1}.0`;
return `${maj}.${min}.${pat + 1}`;
}
function readPkgVersion(): string {
const pkg = JSON.parse(readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf-8"));
return pkg.version as string;
}
function applyVersionBump(newVersion: string): void {
const pkgPath = path.join(PROJECT_ROOT, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
pkg.version = newVersion;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
}
// --- deployed version check ---
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 interval = 15_000;
const maxAttempts = Math.ceil(timeoutMs / interval);
let last = "";
for (let i = 1; i <= maxAttempts; i++) {
await new Promise((r) => setTimeout(r, interval));
try {
const res = await fetch(url);
const data = (await res.json()) as { version?: string };
last = data.version ?? "?";
if (last === expected) {
return `Versi terverifikasi di stg: ${last}`;
}
} catch {
last = "error fetch";
}
}
return `Timeout: versi stg masih ${last}, expected ${expected}`;
}
// --- MCP server ---
const server = new Server(
{ name: "deploy-stg", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "deploy",
description:
"Full deploy ke stg: bump version, commit, push ke build remote, publish Docker image, tunggu selesai, repull Portainer, verifikasi versi.",
inputSchema: {
type: "object",
properties: {
stack_name: {
type: "string",
description: "Nama stack Portainer. Jika tidak diisi, pakai env STACK_NAME.",
},
bump: {
type: "string",
enum: ["patch", "minor", "major"],
description: "Jenis bump versi (default: patch)",
default: "patch",
},
},
required: [],
},
},
{
name: "publish",
description:
"Trigger workflow publish.yml: build & push Docker image ke GHCR (selalu stg, tag dari package.json). Kembalikan URL run.",
inputSchema: { type: "object", properties: {}, required: [] },
},
{
name: "repull",
description:
"Trigger workflow re-pull.yml: redeploy stack di Portainer stg dengan pull image terbaru. Kembalikan URL run.",
inputSchema: {
type: "object",
properties: {
stack_name: {
type: "string",
description: "Nama stack Portainer. Jika tidak diisi, pakai env STACK_NAME.",
},
},
required: [],
},
},
{
name: "run_status",
description:
"Cek status GitHub Actions run terbaru untuk workflow tertentu, atau semua workflow.",
inputSchema: {
type: "object",
properties: {
workflow: {
type: "string",
enum: ["publish.yml", "re-pull.yml", "all"],
description: "Nama workflow file atau 'all' untuk semua (default: all)",
default: "all",
},
limit: {
type: "number",
description: "Jumlah run yang ditampilkan (default 5)",
default: 5,
},
},
required: [],
},
},
{
name: "check_version",
description:
"Bandingkan versi lokal (package.json) dengan versi yang berjalan di stg (/api/version-app).",
inputSchema: { type: "object", properties: {}, required: [] },
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
// ── deploy ─────────────────────────────────────────────────────────────
if (name === "deploy") {
const { stack_name: _sn, bump = "patch" } = (args ?? {}) as {
stack_name?: string;
bump?: "patch" | "minor" | "major";
};
const stack_name = _sn || DEFAULT_STACK_NAME;
if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong.");
// 0. Cek migrasi — buat otomatis jika schema ada perubahan
let migrationCreated = false;
try {
execFileSync(
"./node_modules/.bin/prisma",
["migrate", "diff", "--from-migrations", "prisma/migrations", "--to-schema-datamodel", "prisma/schema.prisma", "--exit-code"],
{ encoding: "utf-8", cwd: PROJECT_ROOT, stdio: "pipe" }
);
} catch {
// Ada schema diff — buat migration otomatis
execFileSync(
"./node_modules/.bin/prisma",
["migrate", "dev", "--create-only", "--name", "auto"],
{ encoding: "utf-8", cwd: PROJECT_ROOT, stdio: "pipe" }
);
migrationCreated = true;
}
const oldVersion = readPkgVersion();
const newVersion = bumpVersion(oldVersion, bump);
// 1. Bump version in package.json
applyVersionBump(newVersion);
// 2. Commit (version bump + migration jika ada)
GIT(["add", "package.json", "prisma/migrations"]);
GIT(["commit", "-m", migrationCreated
? `bump: version ${newVersion} + migration`
: `bump: version ${newVersion}`
]);
// 3. Push to build remote (GitHub)
const currentBranch = GIT(["rev-parse", "--abbrev-ref", "HEAD"]);
GIT(["push", "build", `${currentBranch}:stg`, "--force"]);
// 4. Trigger publish
GH([
"workflow", "run", "publish.yml",
"--repo", REPO,
"--field", `stack_env=${STACK_ENV}`,
"--field", `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",
]);
// 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",
});
// 6. Trigger repull
GH([
"workflow", "run", "re-pull.yml",
"--repo", REPO,
"--field", `stack_name=${stack_name}`,
"--field", `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",
]);
// 7. Wait for repull, then verify version
await new Promise((r) => setTimeout(r, 30_000));
const versionCheck = await waitForDeployedVersion(newVersion);
const localVer = readPkgVersion();
return {
content: [
{
type: "text",
text: [
`Deploy selesai: ${stack_name}-${STACK_ENV} @ ${newVersion} (dari ${oldVersion})`,
`Publish run : ${publishUrl}`,
`Repull run : ${repullUrl}`,
``,
`Versi lokal : ${localVer}`,
versionCheck,
].join("\n"),
},
],
};
}
// ── publish ────────────────────────────────────────────────────────────
if (name === "publish") {
const tag = readPkgVersion();
GH([
"workflow", "run", "publish.yml",
"--repo", REPO,
"--field", `stack_env=${STACK_ENV}`,
"--field", `tag=${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",
]);
return {
content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${runUrl}` }],
};
}
// ── repull ─────────────────────────────────────────────────────────────
if (name === "repull") {
const { stack_name: _sn } = (args ?? {}) as { stack_name?: string };
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 new Promise((r) => setTimeout(r, 3000));
const runUrl = GH([
"run", "list", "--repo", REPO,
"--workflow", "re-pull.yml",
"--limit", "1",
"--json", "url",
"--jq", ".[0].url",
]);
return {
content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${runUrl}` }],
};
}
// ── run_status ─────────────────────────────────────────────────────────
if (name === "run_status") {
const { workflow = "all", limit = 5 } = (args ?? {}) as {
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)"',
]);
return {
content: [{ type: "text", text: output || "Tidak ada run ditemukan." }],
};
}
// ── check_version ──────────────────────────────────────────────────────
if (name === "check_version") {
const localVersion = readPkgVersion();
let stgVersion = "tidak dapat dijangkau";
if (BASE_URL) {
try {
const res = await fetch(`${BASE_URL}/api/version-app`);
const data = (await res.json()) as { version?: string };
stgVersion = data.version ?? "?";
} catch (e) {
stgVersion = `error: ${(e as Error).message}`;
}
} else {
stgVersion = "BASE_URL tidak di-set";
}
const match = localVersion === stgVersion ? "✓ sama" : "✗ beda";
return {
content: [
{
type: "text",
text: [
`Lokal (package.json) : ${localVersion}`,
`Stg (/api/version-app): ${stgVersion}`,
`Status : ${match}`,
].join("\n"),
},
],
};
}
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
}
});
const transport = new StdioServerTransport();
await server.connect(transport);

31
CLAUDE.md Normal file
View File

@@ -0,0 +1,31 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development
bun install # Install dependencies
bun run dev # Dev server with experimental HTTPS (localhost:3000)
bun run build # Production build
bun run start # Start production server
bun run lint # Run ESLint
# Database
npx prisma migrate dev # Run/create migrations
npx prisma db seed # Seed with initial data
npx prisma generate # Regenerate Prisma client after schema changes
```
## Architecture
See @.claude/ARCHITECTURE.md
## Environment Variables
See @.claude/ENV.md
## Deployment
See @.claude/DEPLOYMENT.md

83
Dockerfile Normal file
View File

@@ -0,0 +1,83 @@
# ==============================
# Stage 1: Builder (Bun)
# ==============================
FROM oven/bun:1.3.6-debian AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libc6 \
git \
openssl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json bun.lockb* ./
COPY prisma ./prisma
ENV ONNXRUNTIME_NODE_INSTALL_CUDA=0
ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1
ENV NEXT_TELEMETRY_DISABLED=1
RUN bun install
COPY . .
# Gunakan .env jika ada, fallback ke .env.example.
# Untuk build dengan .env custom, hapus .env dari .dockerignore
# atau berikan via: docker build --secret id=env,src=.env (BuildKit)
RUN if [ -f .env ]; then \
echo "INFO: Menggunakan .env"; \
elif [ -f .env.example ]; then \
cp .env.example .env; \
echo "WARNING: .env tidak ditemukan, menggunakan .env.example (isi dengan nilai yang benar)"; \
else \
echo "WARNING: Tidak ada .env atau .env.example"; \
fi
# Generate prisma client
RUN ./node_modules/.bin/prisma generate
# Build Next.js
RUN bun run build
# ==============================
# Stage 2: Runner (Bun)
# ==============================
FROM oven/bun:1.3.6-debian AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/tsconfig.json ./tsconfig.json
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/src ./src
# Env vars runtime dikelola oleh Portainer (stack env / container env).
# Tidak perlu copy .env ke runner — image tetap bersih tanpa secrets.
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["bun", "run", "start"]

View File

@@ -1,6 +1,6 @@
{ {
"name": "sistem-desa-mandiri", "name": "sistem-desa-mandiri",
"version": "0.1.0", "version": "0.1.7",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --experimental-https", "dev": "next dev --experimental-https",

View File

@@ -0,0 +1,879 @@
-- CreateTable
CREATE TABLE "AdminRole" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Admin" (
"id" TEXT NOT NULL,
"idAdminRole" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"email" TEXT,
"gender" TEXT NOT NULL DEFAULT 'M',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Admin_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserRole" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"desc" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Village" (
"id" TEXT NOT NULL,
"idTheme" TEXT,
"name" TEXT NOT NULL,
"desc" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Village_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Group" (
"id" TEXT NOT NULL,
"idVillage" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Position" (
"id" TEXT NOT NULL,
"idGroup" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Position_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"idUserRole" TEXT NOT NULL,
"idVillage" TEXT NOT NULL,
"idGroup" TEXT NOT NULL,
"idPosition" TEXT,
"nik" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"email" TEXT,
"gender" TEXT NOT NULL DEFAULT 'M',
"img" TEXT,
"isFirstLogin" BOOLEAN NOT NULL DEFAULT true,
"isWithoutOTP" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TokenDeviceUser" (
"id" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"token" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TokenDeviceUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserLog" (
"id" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"action" TEXT NOT NULL,
"desc" TEXT NOT NULL,
"idContent" TEXT NOT NULL,
"tbContent" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Announcement" (
"id" TEXT NOT NULL,
"idVillage" TEXT NOT NULL,
"title" TEXT NOT NULL,
"desc" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Announcement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnnouncementMember" (
"id" TEXT NOT NULL,
"idAnnouncement" TEXT NOT NULL,
"idGroup" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AnnouncementMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnnouncementFile" (
"id" TEXT NOT NULL,
"idAnnouncement" TEXT NOT NULL,
"name" TEXT NOT NULL,
"extension" TEXT NOT NULL,
"idStorage" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AnnouncementFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"idVillage" TEXT NOT NULL,
"idGroup" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" INTEGER NOT NULL DEFAULT 0,
"desc" TEXT,
"reason" TEXT,
"report" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectMember" (
"id" TEXT NOT NULL,
"idProject" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"isLeader" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectFile" (
"id" TEXT NOT NULL,
"idProject" TEXT NOT NULL,
"name" TEXT NOT NULL,
"extension" TEXT NOT NULL,
"idStorage" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectLink" (
"id" TEXT NOT NULL,
"idProject" TEXT NOT NULL,
"link" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectTask" (
"id" TEXT NOT NULL,
"idProject" TEXT NOT NULL,
"title" TEXT NOT NULL,
"desc" TEXT,
"status" INTEGER NOT NULL DEFAULT 0,
"notifikasi" BOOLEAN NOT NULL DEFAULT false,
"dateStart" DATE NOT NULL,
"dateEnd" DATE NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectTaskDetail" (
"id" TEXT NOT NULL,
"idTask" TEXT NOT NULL,
"date" DATE NOT NULL,
"timeStart" TIME,
"timeEnd" TIME,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectTaskDetail_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Division" (
"id" TEXT NOT NULL,
"idVillage" TEXT NOT NULL,
"idGroup" TEXT NOT NULL,
"name" TEXT NOT NULL,
"desc" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Division_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionMember" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"isLeader" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionProject" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"title" TEXT NOT NULL,
"desc" TEXT,
"reason" TEXT,
"report" TEXT,
"status" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionProject_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionProjectLink" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"idProject" TEXT NOT NULL,
"link" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionProjectLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionProjectTask" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"idProject" TEXT NOT NULL,
"title" TEXT NOT NULL,
"desc" TEXT,
"status" INTEGER NOT NULL DEFAULT 0,
"notifikasi" BOOLEAN NOT NULL DEFAULT false,
"dateStart" DATE NOT NULL,
"dateEnd" DATE NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionProjectTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionProjectTaskDetail" (
"id" TEXT NOT NULL,
"idTask" TEXT NOT NULL,
"date" DATE NOT NULL,
"timeStart" TIME,
"timeEnd" TIME,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionProjectTaskDetail_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionProjectMember" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"idProject" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"isLeader" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionProjectMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionProjectFile" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"idProject" TEXT NOT NULL,
"idFile" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionProjectFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionDisscussion" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"title" TEXT,
"desc" TEXT NOT NULL,
"status" INTEGER NOT NULL DEFAULT 1,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionDisscussion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionDisscussionComment" (
"id" TEXT NOT NULL,
"idDisscussion" TEXT NOT NULL,
"comment" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"isEdited" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionDisscussionComment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionDiscussionFile" (
"id" TEXT NOT NULL,
"idDiscussion" TEXT NOT NULL,
"name" TEXT NOT NULL,
"extension" TEXT NOT NULL,
"idStorage" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionDiscussionFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionDocumentFolderFile" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"idStorage" TEXT,
"category" TEXT NOT NULL DEFAULT 'FOLDER',
"name" TEXT NOT NULL,
"extension" TEXT NOT NULL,
"path" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionDocumentFolderFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionDocumentShare" (
"id" TEXT NOT NULL,
"idDocument" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionDocumentShare_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionCalendar" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"title" TEXT NOT NULL,
"desc" TEXT NOT NULL,
"linkMeet" TEXT,
"dateStart" DATE NOT NULL,
"dateEnd" DATE,
"timeStart" TIME NOT NULL,
"timeEnd" TIME NOT NULL,
"repeatEventTyper" TEXT NOT NULL,
"repeatValue" INTEGER NOT NULL DEFAULT 1,
"reminderInterval" TEXT,
"status" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DivisionCalendar_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionCalendarReminder" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"idCalendar" TEXT NOT NULL,
"dateStart" DATE NOT NULL,
"dateEnd" DATE,
"timeStart" TIME NOT NULL,
"timeEnd" TIME NOT NULL,
"status" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionCalendarReminder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DivisionCalendarMember" (
"id" TEXT NOT NULL,
"idCalendar" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DivisionCalendarMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContainerImage" (
"id" TEXT NOT NULL,
"category" TEXT NOT NULL,
"idCategory" TEXT NOT NULL,
"extension" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContainerImage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContainerFileDivision" (
"id" TEXT NOT NULL,
"idDivision" TEXT NOT NULL,
"idStorage" TEXT,
"name" TEXT NOT NULL,
"extension" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContainerFileDivision_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ColorTheme" (
"id" TEXT NOT NULL,
"idVillage" TEXT,
"name" TEXT NOT NULL,
"utama" TEXT NOT NULL,
"bgUtama" TEXT NOT NULL,
"bgIcon" TEXT NOT NULL,
"bgFiturHome" TEXT NOT NULL,
"bgFiturDivision" TEXT NOT NULL,
"bgTotalKegiatan" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ColorTheme_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BannerImage" (
"id" TEXT NOT NULL,
"idVillage" TEXT,
"title" TEXT NOT NULL,
"extension" TEXT NOT NULL,
"image" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BannerImage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notifications" (
"id" TEXT NOT NULL,
"idUserTo" TEXT NOT NULL,
"idUserFrom" TEXT NOT NULL,
"category" TEXT NOT NULL,
"idContent" TEXT NOT NULL,
"title" TEXT NOT NULL,
"desc" TEXT NOT NULL,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Notifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscribe" (
"id" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"subscription" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3),
CONSTRAINT "Subscribe_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Discussion" (
"id" TEXT NOT NULL,
"idVillage" TEXT NOT NULL,
"idGroup" TEXT NOT NULL,
"title" TEXT,
"desc" TEXT NOT NULL,
"status" INTEGER NOT NULL DEFAULT 1,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Discussion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DiscussionMember" (
"id" TEXT NOT NULL,
"idDiscussion" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DiscussionMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DiscussionComment" (
"id" TEXT NOT NULL,
"idDiscussion" TEXT NOT NULL,
"idUser" TEXT NOT NULL,
"comment" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isEdited" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DiscussionComment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DiscussionFile" (
"id" TEXT NOT NULL,
"idDiscussion" TEXT NOT NULL,
"name" TEXT NOT NULL,
"extension" TEXT NOT NULL,
"idStorage" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DiscussionFile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Setting" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"value" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Setting_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Admin_phone_key" ON "Admin"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "Admin_email_key" ON "Admin"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_nik_key" ON "User"("nik");
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Subscribe_idUser_key" ON "Subscribe"("idUser");
-- AddForeignKey
ALTER TABLE "Admin" ADD CONSTRAINT "Admin_idAdminRole_fkey" FOREIGN KEY ("idAdminRole") REFERENCES "AdminRole"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Group" ADD CONSTRAINT "Group_idVillage_fkey" FOREIGN KEY ("idVillage") REFERENCES "Village"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Position" ADD CONSTRAINT "Position_idGroup_fkey" FOREIGN KEY ("idGroup") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_idUserRole_fkey" FOREIGN KEY ("idUserRole") REFERENCES "UserRole"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_idVillage_fkey" FOREIGN KEY ("idVillage") REFERENCES "Village"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_idGroup_fkey" FOREIGN KEY ("idGroup") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_idPosition_fkey" FOREIGN KEY ("idPosition") REFERENCES "Position"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TokenDeviceUser" ADD CONSTRAINT "TokenDeviceUser_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserLog" ADD CONSTRAINT "UserLog_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_idVillage_fkey" FOREIGN KEY ("idVillage") REFERENCES "Village"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnnouncementMember" ADD CONSTRAINT "AnnouncementMember_idAnnouncement_fkey" FOREIGN KEY ("idAnnouncement") REFERENCES "Announcement"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnnouncementMember" ADD CONSTRAINT "AnnouncementMember_idGroup_fkey" FOREIGN KEY ("idGroup") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnnouncementMember" ADD CONSTRAINT "AnnouncementMember_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnnouncementFile" ADD CONSTRAINT "AnnouncementFile_idAnnouncement_fkey" FOREIGN KEY ("idAnnouncement") REFERENCES "Announcement"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_idVillage_fkey" FOREIGN KEY ("idVillage") REFERENCES "Village"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_idGroup_fkey" FOREIGN KEY ("idGroup") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectMember" ADD CONSTRAINT "ProjectMember_idProject_fkey" FOREIGN KEY ("idProject") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectMember" ADD CONSTRAINT "ProjectMember_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_idProject_fkey" FOREIGN KEY ("idProject") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectLink" ADD CONSTRAINT "ProjectLink_idProject_fkey" FOREIGN KEY ("idProject") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectTask" ADD CONSTRAINT "ProjectTask_idProject_fkey" FOREIGN KEY ("idProject") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectTaskDetail" ADD CONSTRAINT "ProjectTaskDetail_idTask_fkey" FOREIGN KEY ("idTask") REFERENCES "ProjectTask"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Division" ADD CONSTRAINT "Division_idVillage_fkey" FOREIGN KEY ("idVillage") REFERENCES "Village"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Division" ADD CONSTRAINT "Division_idGroup_fkey" FOREIGN KEY ("idGroup") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Division" ADD CONSTRAINT "Division_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionMember" ADD CONSTRAINT "DivisionMember_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionMember" ADD CONSTRAINT "DivisionMember_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProject" ADD CONSTRAINT "DivisionProject_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectLink" ADD CONSTRAINT "DivisionProjectLink_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectLink" ADD CONSTRAINT "DivisionProjectLink_idProject_fkey" FOREIGN KEY ("idProject") REFERENCES "DivisionProject"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectTask" ADD CONSTRAINT "DivisionProjectTask_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectTask" ADD CONSTRAINT "DivisionProjectTask_idProject_fkey" FOREIGN KEY ("idProject") REFERENCES "DivisionProject"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectTaskDetail" ADD CONSTRAINT "DivisionProjectTaskDetail_idTask_fkey" FOREIGN KEY ("idTask") REFERENCES "DivisionProjectTask"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectMember" ADD CONSTRAINT "DivisionProjectMember_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectMember" ADD CONSTRAINT "DivisionProjectMember_idProject_fkey" FOREIGN KEY ("idProject") REFERENCES "DivisionProject"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectMember" ADD CONSTRAINT "DivisionProjectMember_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectFile" ADD CONSTRAINT "DivisionProjectFile_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectFile" ADD CONSTRAINT "DivisionProjectFile_idProject_fkey" FOREIGN KEY ("idProject") REFERENCES "DivisionProject"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectFile" ADD CONSTRAINT "DivisionProjectFile_idFile_fkey" FOREIGN KEY ("idFile") REFERENCES "ContainerFileDivision"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionProjectFile" ADD CONSTRAINT "DivisionProjectFile_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionDisscussion" ADD CONSTRAINT "DivisionDisscussion_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionDisscussion" ADD CONSTRAINT "DivisionDisscussion_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionDisscussionComment" ADD CONSTRAINT "DivisionDisscussionComment_idDisscussion_fkey" FOREIGN KEY ("idDisscussion") REFERENCES "DivisionDisscussion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionDisscussionComment" ADD CONSTRAINT "DivisionDisscussionComment_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionDiscussionFile" ADD CONSTRAINT "DivisionDiscussionFile_idDiscussion_fkey" FOREIGN KEY ("idDiscussion") REFERENCES "DivisionDisscussion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionDocumentFolderFile" ADD CONSTRAINT "DivisionDocumentFolderFile_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionDocumentFolderFile" ADD CONSTRAINT "DivisionDocumentFolderFile_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionDocumentShare" ADD CONSTRAINT "DivisionDocumentShare_idDocument_fkey" FOREIGN KEY ("idDocument") REFERENCES "DivisionDocumentFolderFile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionDocumentShare" ADD CONSTRAINT "DivisionDocumentShare_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionCalendar" ADD CONSTRAINT "DivisionCalendar_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionCalendar" ADD CONSTRAINT "DivisionCalendar_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionCalendarReminder" ADD CONSTRAINT "DivisionCalendarReminder_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionCalendarReminder" ADD CONSTRAINT "DivisionCalendarReminder_idCalendar_fkey" FOREIGN KEY ("idCalendar") REFERENCES "DivisionCalendar"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionCalendarMember" ADD CONSTRAINT "DivisionCalendarMember_idCalendar_fkey" FOREIGN KEY ("idCalendar") REFERENCES "DivisionCalendar"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DivisionCalendarMember" ADD CONSTRAINT "DivisionCalendarMember_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContainerFileDivision" ADD CONSTRAINT "ContainerFileDivision_idDivision_fkey" FOREIGN KEY ("idDivision") REFERENCES "Division"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ColorTheme" ADD CONSTRAINT "ColorTheme_idVillage_fkey" FOREIGN KEY ("idVillage") REFERENCES "Village"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BannerImage" ADD CONSTRAINT "BannerImage_idVillage_fkey" FOREIGN KEY ("idVillage") REFERENCES "Village"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notifications" ADD CONSTRAINT "UserToUserMap" FOREIGN KEY ("idUserTo") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notifications" ADD CONSTRAINT "UserFromUserMap" FOREIGN KEY ("idUserFrom") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscribe" ADD CONSTRAINT "Subscribe_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Discussion" ADD CONSTRAINT "Discussion_idVillage_fkey" FOREIGN KEY ("idVillage") REFERENCES "Village"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Discussion" ADD CONSTRAINT "Discussion_idGroup_fkey" FOREIGN KEY ("idGroup") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Discussion" ADD CONSTRAINT "Discussion_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DiscussionMember" ADD CONSTRAINT "DiscussionMember_idDiscussion_fkey" FOREIGN KEY ("idDiscussion") REFERENCES "Discussion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DiscussionMember" ADD CONSTRAINT "DiscussionMember_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_idDiscussion_fkey" FOREIGN KEY ("idDiscussion") REFERENCES "Discussion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_idUser_fkey" FOREIGN KEY ("idUser") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DiscussionFile" ADD CONSTRAINT "DiscussionFile_idDiscussion_fkey" FOREIGN KEY ("idDiscussion") REFERENCES "Discussion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Village" ADD COLUMN "isDummy" BOOLEAN NOT NULL DEFAULT false;

View File

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

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -51,6 +51,7 @@ model Village {
name String name String
desc String @db.Text desc String @db.Text
isActive Boolean @default(true) isActive Boolean @default(true)
isDummy Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
Group Group[] Group Group[]

View File

@@ -7,7 +7,7 @@ export async function POST(req: NextRequest) {
const { phone }: ILogin = await req.json(); const { phone }: ILogin = await req.json();
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { phone, isActive: true }, where: { phone, isActive: true },
select: { id: true, phone: true, isWithoutOTP: true }, select: { id: true, phone: true, isWithoutOTP: true, Village: { select: { isActive: true } } },
}); });
if (!user) { if (!user) {
@@ -17,6 +17,13 @@ export async function POST(req: NextRequest) {
}); });
} }
if (!user.Village?.isActive) {
return Response.json({
success: false,
message: "Akun anda tidak aktif, silahkan hubungi admin",
});
}
return Response.json({ return Response.json({
success: true, success: true,
message: "Sukses", message: "Sukses",

View File

@@ -0,0 +1,59 @@
import { prisma } from "@/module/_global";
import { ILogin } from "@/types";
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
try {
const { phone }: ILogin = await req.json();
const user = await prisma.user.findUnique({
where: { phone, isActive: true },
select: { id: true, phone: true, isWithoutOTP: true },
});
if (!user) {
return Response.json({
success: false,
message: "Nomor telepon tidak terdaftar",
});
}
// Generate OTP
const code = Math.floor(1000 + Math.random() * 9000);
const message = `Desa+\nMasukkan kode ini ${code} pada web app Desa+ anda. Jangan berikan pada siapapun.`;
// Send WhatsApp
try {
const resWa = await fetch(`${process.env.URL_OTP}/api/wa/send-text`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.WA_SERVER_TOKEN}`,
},
body: JSON.stringify({
number: user.phone,
text: message,
}),
});
if (!resWa.ok) {
console.error("WhatsApp API Error:", resWa.status);
}
} catch (error) {
console.error("WhatsApp Fetch Error:", error);
}
return Response.json({
success: true,
message: "Sukses",
phone: user.phone,
isWithoutOTP: user.isWithoutOTP,
id: user.id,
otp: code, // Return OTP for client-side verification (as per existing logic)
});
} catch (error) {
console.error(error);
return Response.json({ message: "Internal Server Error (error: 500)", success: false });
}
}

View File

@@ -2,6 +2,7 @@ import { prisma } from "@/module/_global";
import { funGetUserById } from "@/module/auth"; import { funGetUserById } from "@/module/auth";
import _, { ceil } from "lodash"; import _, { ceil } from "lodash";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import moment from "moment";
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
@@ -38,10 +39,10 @@ export async function GET(request: Request) {
DivisionProjectTask: { DivisionProjectTask: {
some: { some: {
dateStart: { dateStart: {
gte: new Date(String(date)) gte: moment(String(date)).startOf('day').toDate()
}, },
dateEnd: { dateEnd: {
lte: new Date(String(dateAkhir)) lte: moment(String(dateAkhir)).endOf('day').toDate()
} }
} }
} }
@@ -54,10 +55,10 @@ export async function GET(request: Request) {
DivisionProjectTask: { DivisionProjectTask: {
some: { some: {
dateStart: { dateStart: {
gte: new Date(String(date)) gte: moment(String(date)).startOf('day').toDate()
}, },
dateEnd: { dateEnd: {
lte: new Date(String(dateAkhir)) lte: moment(String(dateAkhir)).endOf('day').toDate()
} }
} }
} }
@@ -102,10 +103,10 @@ export async function GET(request: Request) {
DivisionProjectTask: { DivisionProjectTask: {
some: { some: {
dateStart: { dateStart: {
gte: new Date(String(date)) gte: moment(String(date)).startOf('day').toDate()
}, },
dateEnd: { dateEnd: {
lte: new Date(String(dateAkhir)) lte: moment(String(dateAkhir)).endOf('day').toDate()
} }
} }
} }
@@ -117,10 +118,10 @@ export async function GET(request: Request) {
DivisionProjectTask: { DivisionProjectTask: {
some: { some: {
dateStart: { dateStart: {
gte: new Date(String(date)) gte: moment(String(date)).startOf('day').toDate()
}, },
dateEnd: { dateEnd: {
lte: new Date(String(dateAkhir)) lte: moment(String(dateAkhir)).endOf('day').toDate()
} }
} }
} }
@@ -171,8 +172,8 @@ export async function GET(request: Request) {
idGroup: String(grup) idGroup: String(grup)
}, },
createdAt: { createdAt: {
gte: new Date(String(date)), gte: moment(String(date)).startOf('day').toDate(),
lte: new Date(String(dateAkhir)) lte: moment(String(dateAkhir)).endOf('day').toDate()
}, },
} }
} else { } else {
@@ -181,8 +182,8 @@ export async function GET(request: Request) {
category: 'FILE', category: 'FILE',
idDivision: String(division), idDivision: String(division),
createdAt: { createdAt: {
gte: new Date(String(date)), gte: moment(String(date)).startOf('day').toDate(),
lte: new Date(String(dateAkhir)) lte: moment(String(dateAkhir)).endOf('day').toDate()
}, },
} }
} }
@@ -252,8 +253,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: { DivisionCalendarReminder: {
some: { some: {
dateStart: { dateStart: {
gte: new Date(String(date)), gte: moment(String(date)).startOf('day').toDate(),
lte: new Date() lte: moment().toDate()
} }
} }
} }
@@ -267,8 +268,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: { DivisionCalendarReminder: {
some: { some: {
dateStart: { dateStart: {
gt: new Date(), gt: moment().toDate(),
lte: new Date(String(dateAkhir)) lte: moment(String(dateAkhir)).endOf('day').toDate()
} }
} }
} }
@@ -293,8 +294,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: { DivisionCalendarReminder: {
some: { some: {
dateStart: { dateStart: {
gte: new Date(String(date)), gte: moment(String(date)).startOf('day').toDate(),
lte: new Date() lte: moment().toDate()
} }
} }
} }
@@ -306,8 +307,8 @@ export async function GET(request: Request) {
DivisionCalendarReminder: { DivisionCalendarReminder: {
some: { some: {
dateStart: { dateStart: {
gt: new Date(), gt: moment().toDate(),
lte: new Date(String(dateAkhir)) lte: moment(String(dateAkhir)).endOf('day').toDate()
} }
} }
} }

View File

@@ -44,7 +44,8 @@ export async function GET(request: Request, context: { params: { id: string } })
}, },
Village:{ Village:{
select:{ select:{
name:true name:true,
isActive:true,
} }
} }
}, },
@@ -57,8 +58,9 @@ export async function GET(request: Request, context: { params: { id: string } })
const phone = users?.phone.substr(2) const phone = users?.phone.substr(2)
const role = users?.UserRole.name const role = users?.UserRole.name
const village = users?.Village.name const village = users?.Village.name
const villageIsActive = users?.Village.isActive
const result = { ...userData, group, position, idUserRole, phone, role, village }; const result = { ...userData, group, position, idUserRole, phone, role, village, villageIsActive };
const omitData = _.omit(result, ["Group", "Position", "UserRole", "Village"]); const omitData = _.omit(result, ["Group", "Position", "UserRole", "Village"]);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,687 @@
import { prisma } from "@/module/_global";
import cors from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import Elysia, { t } from "elysia";
import _ from "lodash";
import moment from "moment";
import "moment/locale/id";
// Gabungkan semua ke dalam satu instance server yang dipasang di /api/noc
const NocServer = new Elysia({ prefix: "/api/noc" })
.use(cors({
origin: "*",
methods: ["GET", "POST", "OPTIONS"],
}))
.use(swagger({
path: "/docs", // Karena prefix instance adalah /api/noc, maka ini akan diakses di /api/noc/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",
},
tags: [
{ name: "NOC", description: "Endpoint khusus monitoring" }
]
}
}))
// ── GET /api/noc/active-divisions ──────────────────────────────────────────
.get(
"/active-divisions",
async ({ query, set }) => {
const { idDesa, limit } = query;
if (!idDesa) {
set.status = 400;
return {
success: false,
message: "Parameter idDesa wajib diisi",
data: null,
};
}
const maxResults = Number(limit ?? 5);
try {
// Cek apakah desa ada
const village = await prisma.village.findUnique({
where: { id: idDesa },
select: { id: true, name: true },
});
if (!village) {
set.status = 404;
return {
success: false,
message: "Desa tidak ditemukan",
data: null,
};
}
// Ambil semua divisi milik desa ini
const divisions = await prisma.division.findMany({
where: {
idVillage: idDesa,
isActive: true,
},
select: {
id: true,
name: true,
idGroup: true,
Group: {
select: {
name: true,
},
},
_count: {
select: {
DivisionProject: true,
},
},
},
});
// Hitung total kegiatan per divisi & urutkan descending, ambil top sesuai limit
const ranked = divisions
.map((d: any) => ({
id: d.id,
division: d.name,
group: d.Group.name,
totalKegiatan: d._count.DivisionProject
}))
.sort((a: any, b: any) => b.totalKegiatan - a.totalKegiatan)
.slice(0, maxResults);
return {
success: true,
message: "Berhasil mendapatkan divisi teraktif",
data: {
idDesa: village.id,
namaDesa: village.name,
divisi: ranked,
},
};
} catch (error) {
console.error("[NOC] active-divisions error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
query: t.Object({
idDesa: t.String({ description: "ID Desa yang ingin dicari" }),
limit: t.Optional(t.String({ description: "Jumlah maksimal data (default: 5)" })),
}),
detail: {
summary: "Divisi Teraktif",
description: "Menu Beranda - Mendapatkan daftar divisi teraktif berdasarkan jumlah proyek pada desa tertentu.",
tags: ["NOC"],
},
}
)
// ── GET /api/noc/latest-projects ──────────────────────────────────────────
.get(
"/latest-projects",
async ({ query, set }) => {
const { idDesa, limit } = query;
if (!idDesa) {
set.status = 400;
return {
success: false,
message: "Parameter idDesa wajib diisi",
data: null,
};
}
const maxResults = Math.min(Number(limit ?? 5), 50);
try {
// Cek apakah desa ada
const village = await prisma.village.findUnique({
where: { id: idDesa },
select: { id: true, name: true },
});
if (!village) {
set.status = 404;
return {
success: false,
message: "Desa tidak ditemukan",
data: null,
};
}
// Ambil proyek umum terbaru dari desa ini
const projects = await prisma.project.findMany({
where: {
idVillage: idDesa,
isActive: true,
},
select: {
id: true,
title: true,
status: true,
desc: true,
updatedAt: true,
Group: {
select: {
name: true,
},
},
User: {
select: {
name: true,
},
},
},
orderBy: {
updatedAt: "desc",
},
take: maxResults,
});
const mapped = projects.map((p: any) => ({
id: p.id,
title: p.title,
status: p.status,
desc: p.desc,
group: p.Group.name,
createdBy: p.User.name,
updatedAt: p.updatedAt,
}));
return {
success: true,
message: "Berhasil mendapatkan proyek terbaru",
data: {
idDesa: village.id,
namaDesa: village.name,
total: mapped.length,
projects: mapped,
},
};
} catch (error) {
console.error("[NOC] latest-projects error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
query: t.Object({
idDesa: t.String({ description: "ID Desa yang ingin dicari" }),
limit: t.Optional(
t.String({ description: "Jumlah maksimal proyek (default: 5, maks: 50)" })
),
}),
detail: {
summary: "Latest Projects General",
description: "Menu kinerja divisi - Mendapatkan daftar proyek umum terbaru dari berbagai grup pada desa tertentu.",
tags: ["NOC"],
},
}
)
// ── GET /api/noc/upcoming-events ───────────────────────────────────────────
.get(
"/upcoming-events",
async ({ query, set }) => {
const { idDesa, limit, filter } = query;
if (!idDesa) {
set.status = 400;
return {
success: false,
message: "Parameter idDesa wajib diisi",
data: null,
};
}
const maxResults = Math.min(Number(limit ?? 10), 50);
const today = moment().startOf("day").toDate();
try {
const village = await prisma.village.findUnique({
where: { id: idDesa },
select: { id: true, name: true },
});
if (!village) {
set.status = 404;
return {
success: false,
message: "Desa tidak ditemukan",
data: null,
};
}
const events = await prisma.divisionCalendarReminder.findMany({
where: {
isActive: true,
dateStart: {
gte: today,
},
Division: {
idVillage: idDesa,
isActive: true,
},
DivisionCalendar: {
isActive: true,
},
},
select: {
id: true,
idCalendar: true,
dateStart: true,
dateEnd: true,
timeStart: true,
timeEnd: true,
status: true,
Division: {
select: {
id: true,
name: true,
},
},
DivisionCalendar: {
select: {
title: true,
desc: true,
linkMeet: true,
repeatEventTyper: true,
User: {
select: {
name: true,
},
},
},
},
},
orderBy: [
{ dateStart: "asc" },
{ timeStart: "asc" },
],
take: maxResults,
});
const todayMoment = moment().startOf("day");
const mapper = (e: any) => ({
id: e.id,
idCalendar: e.idCalendar,
title: e.DivisionCalendar.title,
desc: e.DivisionCalendar.desc,
linkMeet: e.DivisionCalendar.linkMeet ?? null,
repeatEventTyper: e.DivisionCalendar.repeatEventTyper,
dateStart: moment(e.dateStart).format("YYYY-MM-DD"),
dateEnd: e.dateEnd
? moment(e.dateEnd).format("YYYY-MM-DD")
: null,
timeStart: moment.utc(e.timeStart).format("HH:mm"),
timeEnd: moment.utc(e.timeEnd).format("HH:mm"),
status: e.status,
createdBy: e.DivisionCalendar.User.name,
divisi: {
id: e.Division.id,
name: e.Division.name,
},
});
const todayEvents = events.filter((e: any) => moment(e.dateStart).isSame(todayMoment, 'day')).map(mapper);
const upcomingEvents = events.filter((e: any) => moment(e.dateStart).isAfter(todayMoment, 'day')).map(mapper);
let data: any = {
idDesa: village.id,
namaDesa: village.name,
};
if (filter === "today") {
data.events = todayEvents;
} else if (filter === "upcoming") {
data.events = upcomingEvents;
} else {
data.today = todayEvents;
data.upcoming = upcomingEvents;
}
return {
success: true,
message: "Berhasil mendapatkan events",
data: data,
};
} catch (error) {
console.error("[NOC] upcoming-events error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
query: t.Object({
idDesa: t.String({ description: "ID Desa yang ingin dicari" }),
limit: t.Optional(
t.String({ description: "Jumlah maksimal event (default: 10, maks: 50)" })
),
filter: t.Optional(
t.String({ description: "Filter event: 'today' atau 'upcoming'" })
),
}),
detail: {
summary: "Events (Today & Upcoming)",
description: "Menu beranda dan kinerja divisi - Mendapatkan daftar event pada hari ini dan yang akan datang untuk semua divisi pada desa tertentu.",
tags: ["NOC"],
},
}
)
// ── GET /api/noc/diagram-jumlah-document ───────────────────────────────────────────────
.get(
"/diagram-jumlah-document",
async ({ query, set }) => {
const { idDesa } = query;
if (!idDesa) {
set.status = 400;
return {
success: false,
message: "Parameter idDesa wajib diisi",
data: null,
};
}
try {
const village = await prisma.village.findUnique({
where: { id: idDesa },
select: { id: true, name: true },
});
if (!village) {
set.status = 404;
return {
success: false,
message: "Desa tidak ditemukan",
data: null,
};
}
const documents = await prisma.divisionDocumentFolderFile.findMany({
where: {
isActive: true,
category: 'FILE',
Division: {
isActive: true,
idVillage: idDesa,
Group: {
isActive: true,
}
}
}
})
const groupData = _.map(_.groupBy(documents, "extension"), (v: any) => ({
file: v[0].extension,
jumlah: v.length,
}))
const image = ['jpg', 'jpeg', 'png', 'heic']
let hasilImage = {
label: 'Gambar',
value: 0,
color: '#fac858'
}
let hasilFile = {
label: 'Dokumen',
value: 0,
color: '#92cc76'
}
groupData.map((v: any) => {
if (image.some((i: any) => i == v.file)) {
hasilImage = {
label: 'Gambar',
value: hasilImage.value + v.jumlah,
color: '#fac858'
}
} else {
hasilFile = {
label: 'Dokumen',
value: hasilFile.value + v.jumlah,
color: '#92cc76'
}
}
})
const allData = [hasilImage, hasilFile]
return {
success: true,
message: "Berhasil mendapatkan jumlah document",
data: allData
};
} catch (error) {
console.error("[NOC] jumlah-document error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
query: t.Object({
idDesa: t.String({ description: "ID Desa yang ingin dicari" }),
}),
detail: {
summary: "Diagram Jumlah Document",
description: "Menu kinerja divisi - Mendapatkan diagram jumlah document pada desa tertentu.",
tags: ["NOC"],
},
}
)
// -- GET /api/noc/diagram-progres-kegiatan
.get(
"/diagram-progres-kegiatan",
async ({ query, set }) => {
const { idDesa } = query;
if (!idDesa) {
set.status = 400;
return {
success: false,
message: "Parameter idDesa wajib diisi",
data: null,
};
}
try {
const village = await prisma.village.findUnique({
where: { id: idDesa },
select: { id: true, name: true },
});
if (!village) {
set.status = 404;
return {
success: false,
message: "Desa tidak ditemukan",
data: null,
};
}
const data = await prisma.project.groupBy({
where: {
isActive: true,
idVillage: idDesa,
Group: {
isActive: true,
}
},
by: ["status"],
_count: true
})
const dataStatus = [{ name: 'Segera dikerjakan', status: 0, color: '#177AD5' }, { name: 'Dikerjakan', status: 1, color: '#fac858' }, { name: 'Selesai dikerjakan', status: 2, color: '#92cc76' }, { name: 'Dibatalkan', status: 3, color: '#ED6665' }]
const hasil: any[] = []
let input
for (let index = 0; index < dataStatus.length; index++) {
const cek = data.some((i: any) => i.status == dataStatus[index].status)
if (cek) {
const find = ((Number(data.find((i: any) => i.status == dataStatus[index].status)?._count) * 100) / data.reduce((n: any, { _count }: any) => n + _count, 0)).toFixed(2)
const fix = find != "100.00" ? find.substr(-2, 2) == "00" ? find.substr(0, 2) : find : "100"
input = {
text: fix + '%',
value: fix,
color: dataStatus[index].color
}
} else {
input = {
text: '0%',
value: 0,
color: dataStatus[index].color
}
}
hasil.push(input)
}
return {
success: true,
message: "Berhasil mendapatkan progres kegiatan",
data: hasil
};
} catch (error) {
console.error("[NOC] progres-kegiatan error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
query: t.Object({
idDesa: t.String({ description: "ID Desa yang ingin dicari" }),
}),
detail: {
summary: "Diagram Progres Kegiatan",
description: "Menu kinerja divisi - Mendapatkan diagram progres kegiatan pada desa tertentu.",
tags: ["NOC"],
},
}
)
// -- GET /api/noc/latest-discussion
.get(
"/latest-discussion",
async ({ query, set }) => {
const { idDesa, limit } = query;
const maxResults = Math.min(Number(limit ?? 5), 50);
if (!idDesa) {
set.status = 400;
return {
success: false,
message: "Parameter idDesa wajib diisi",
data: null,
};
}
try {
const village = await prisma.village.findUnique({
where: { id: idDesa },
select: { id: true, name: true },
});
if (!village) {
set.status = 404;
return {
success: false,
message: "Desa tidak ditemukan",
data: null,
};
}
const data = await prisma.discussion.findMany({
take: maxResults,
where: {
idVillage: idDesa,
isActive: true,
status: 1,
},
select: {
id: true,
title: true,
desc: true,
createdAt: true,
User: {
select: {
name: true
}
},
Group: {
select: {
name: true
}
}
},
orderBy: {
createdAt: "desc"
}
})
const allData = data.map((v: any) => ({
..._.omit(v, ["createdAt", "User", "Group"]),
date: moment(v.createdAt).format("ll"),
user: v.User.name,
group: v.Group.name
}))
return {
success: true,
message: "Berhasil mendapatkan latest discussion",
data: allData
};
} catch (error) {
console.error("[NOC] latest-discussion error:", error);
set.status = 500;
return {
success: false,
message: "Terjadi kesalahan pada server",
data: null,
};
}
},
{
query: t.Object({
idDesa: t.String({ description: "ID Desa yang ingin dicari" }),
limit: t.Optional(t.String({ description: "Limit data" })),
}),
detail: {
summary: "Latest Discussion",
description: "Menu kinerja divisi - Mendapatkan latest discussion pada desa tertentu.",
tags: ["NOC"],
},
}
);
export const GET = NocServer.handle;
export const POST = NocServer.handle;

View File

@@ -1,10 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { version } from "../../../../package.json";
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
return NextResponse.json({ success: true, version: "2.1.3", tahap: "beta", update: "-revisi api mobile pengumuman, diskusi umum dan diskusi divisi; -ditambah kan file " }, { status: 200 }); return NextResponse.json({ success: true, version, tahap: "beta", update: "-perbaikan fitur diskusi dan perbaikan tampilan mobile" }, { status: 200 });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return NextResponse.json({ success: false, version: "Gagal mendapatkan version, coba lagi nanti (error: 500)", reason: (error as Error).message, }, { status: 500 }); return NextResponse.json({ success: false, version: "0.1.5", reason: (error as Error).message, }, { status: 500 });
} }
} }

41
src/app/global-error.tsx Normal file
View File

@@ -0,0 +1,41 @@
"use client";
export default function GlobalError({
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="en">
<body
style={{
backgroundColor: "#252A2F",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
gap: 16,
fontFamily: "Lato, sans-serif",
color: "white",
}}
>
<h2>Terjadi Kesalahan</h2>
<button
onClick={() => reset()}
style={{
padding: "8px 16px",
borderRadius: 4,
border: "1px solid #ccc",
background: "transparent",
color: "white",
cursor: "pointer",
}}
>
Coba Lagi
</button>
</body>
</html>
);
}

View File

@@ -9,6 +9,8 @@ import '@mantine/notifications/styles.css';
import { Lato } from "next/font/google"; import { Lato } from "next/font/google";
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
export const dynamic = 'force-dynamic';
export const metadata = { export const metadata = {
title: "SISTEM DESA MANDIRI", title: "SISTEM DESA MANDIRI",
description: "I have followed setup instructions carefully", description: "I have followed setup instructions carefully",

24
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { Box, Text, Button } from "@mantine/core";
import Link from "next/link";
export default function NotFound() {
return (
<Box
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
gap: 16,
}}
>
<Text size="xl" fw={700} c="white">
404 - Halaman Tidak Ditemukan
</Text>
<Button component={Link} href="/" variant="light">
Kembali ke Beranda
</Button>
</Box>
);
}

11
src/lib/formatDateTime.ts Normal file
View File

@@ -0,0 +1,11 @@
function formatDateTime(date: Date) {
return new Intl.DateTimeFormat('id-ID', {
hour: '2-digit',
minute: '2-digit',
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(date);
}
export default formatDateTime

38
src/lib/timeAgo.ts Normal file
View File

@@ -0,0 +1,38 @@
function timeAgo(date: Date) {
const now = new Date();
const d = new Date(date);
const diffMs = now.getTime() - d.getTime();
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
// 🔥 cek apakah masih hari yang sama
const isToday =
now.getDate() === d.getDate() &&
now.getMonth() === d.getMonth() &&
now.getFullYear() === d.getFullYear();
if (isToday) {
if (seconds < 60) return `${seconds} detik lalu`;
if (minutes < 60) return `${minutes} menit lalu`;
return `${hours} jam lalu`;
}
// 🔥 kalau bukan hari ini → tampil tanggal + jam
const time = d.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
});
const datePart = d.toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
});
return `${time} ${datePart}`;
}
export default timeAgo

View File

@@ -5,7 +5,6 @@ import { useFocusTrap } from "@mantine/hooks";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import ViewVerification from "../../varification/view/view_verification"; import ViewVerification from "../../varification/view/view_verification";
function ViewLogin() { function ViewLogin() {
const focusTrapRef = useFocusTrap() const focusTrapRef = useFocusTrap()
const textInfo = "Kami akan mengirimkan kode verifikasi melalui WhatsApp untuk mengonfirmasi nomor Anda."; const textInfo = "Kami akan mengirimkan kode verifikasi melalui WhatsApp untuk mengonfirmasi nomor Anda.";
@@ -34,23 +33,24 @@ function ViewLogin() {
}) })
const cekLogin = await cek.json() const cekLogin = await cek.json()
if (cekLogin.success) { if (cekLogin.success) {
const code = Math.floor(1000 + Math.random() * 9000)
try { try {
const res = await fetch(`https://wa.wibudev.com/code?nom=${cekLogin.phone}&text=*DARMASABA*%0A%0A const res = await fetch('/api/auth/otp', {
JANGAN BERIKAN KODE RAHASIA ini kepada siapa pun TERMASUK PIHAK DARMASABA. Masukkan otentikasi: *${encodeURIComponent(code)}*`).then( method: 'POST',
async (res) => { headers: {
if (res.status == 200) { 'Content-Type': 'application/json'
setValPhone(cekLogin.phone) },
setOTP(code) body: JSON.stringify({ phone: isPhone })
setUser(cekLogin.id) })
const data = await res.json()
if (data.success) {
setValPhone(data.phone)
setOTP(data.otp)
setUser(data.id)
setVerif(true) setVerif(true)
toast.success('Kode verifikasi telah dikirim') toast.success('Kode verifikasi telah dikirim')
} else { } else {
console.error(res.status) toast.error(data.message || 'Gagal mengirim kode verifikasi')
toast.error('Internal Server Error')
} }
}
)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
toast.error('Internal Server Error') toast.error('Internal Server Error')

View File

@@ -15,19 +15,20 @@ export default function ViewVerification({ phone, otp, user }: IVerification) {
async function onResend() { async function onResend() {
try { try {
const code = Math.floor(1000 + Math.random() * 9000) const res = await fetch('/api/auth/otp', {
const res = await fetch(`https://wa.wibudev.com/code?nom=${phone}&text=*DARMASABA*%0A%0A method: 'POST',
JANGAN BERIKAN KODE RAHASIA ini kepada siapa pun TERMASUK PIHAK DARMASABA. Masukkan otentikasi: *${encodeURIComponent(code)}*`) headers: {
.then( 'Content-Type': 'application/json'
async (res) => { },
if (res.status == 200) { body: JSON.stringify({ phone })
})
const data = await res.json()
if (data.success) {
toast.success('Kode verifikasi telah dikirim') toast.success('Kode verifikasi telah dikirim')
setOTP(code) setOTP(data.otp)
} else { } else {
toast.error('Internal Server Error') toast.error(data.message || 'Gagal mengirim ulang kode')
} }
}
);
} catch (error) { } catch (error) {
console.error(error) console.error(error)
toast.error('Internal Server Error') toast.error('Internal Server Error')

View File

@@ -19,7 +19,7 @@
"idPosition": "pos_ketua_rt01", "idPosition": "pos_ketua_rt01",
"nik": "3201010101010001", "nik": "3201010101010001",
"name": "Juli Ningrum", "name": "Juli Ningrum",
"phone": "081234567890", "phone": "6281234567890",
"email": "juliningrum@gmail.com", "email": "juliningrum@gmail.com",
"gender": "F" "gender": "F"
}, },
@@ -31,7 +31,7 @@
"idPosition": "pos_sekretaris_rt01", "idPosition": "pos_sekretaris_rt01",
"nik": "3201010101010002", "nik": "3201010101010002",
"name": "Salwa Kusmawati", "name": "Salwa Kusmawati",
"phone": "081234567891", "phone": "6281234567891",
"email": "salwakusmawati@gmail.com", "email": "salwakusmawati@gmail.com",
"gender": "F" "gender": "F"
}, },
@@ -43,7 +43,7 @@
"idPosition": "pos_staff_rt01", "idPosition": "pos_staff_rt01",
"nik": "3201010101010005", "nik": "3201010101010005",
"name": "Bakidin Wibowo", "name": "Bakidin Wibowo",
"phone": "081234567894", "phone": "6281234567894",
"email": "bakidinwibowo@gmail.com", "email": "bakidinwibowo@gmail.com",
"gender": "M" "gender": "M"
}, },
@@ -55,7 +55,7 @@
"idPosition": "pos_staff_rt01", "idPosition": "pos_staff_rt01",
"nik": "3201010101010006", "nik": "3201010101010006",
"name": "Jais Kurniawan", "name": "Jais Kurniawan",
"phone": "081234567895", "phone": "6281234567895",
"email": "jaiskurniawan@gmail.com", "email": "jaiskurniawan@gmail.com",
"gender": "M" "gender": "M"
}, },
@@ -67,7 +67,7 @@
"idPosition": "pos_staff_rt01", "idPosition": "pos_staff_rt01",
"nik": "3201010101010007", "nik": "3201010101010007",
"name": "Safira Oktaviani S.I.Kom", "name": "Safira Oktaviani S.I.Kom",
"phone": "081234567896", "phone": "6281234567896",
"email": "safiraoktaviani@gmail.com", "email": "safiraoktaviani@gmail.com",
"gender": "F" "gender": "F"
}, },
@@ -79,7 +79,7 @@
"idPosition": "pos_staff_rt01", "idPosition": "pos_staff_rt01",
"nik": "3201010101010008", "nik": "3201010101010008",
"name": "Agus Setiawan", "name": "Agus Setiawan",
"phone": "081234567897", "phone": "6281234567897",
"email": "agussetiawannn@gmail.com", "email": "agussetiawannn@gmail.com",
"gender": "M" "gender": "M"
} }

30
src/pages/_error.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { NextPageContext } from "next";
function ErrorPage({ statusCode }: { statusCode?: number }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
backgroundColor: "#252A2F",
color: "white",
fontFamily: "Lato, sans-serif",
}}
>
<h1 style={{ fontSize: 24, fontWeight: 700 }}>
{statusCode === 404
? "404 - Halaman Tidak Ditemukan"
: "Terjadi Kesalahan"}
</h1>
</div>
);
}
ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
return { statusCode };
};
export default ErrorPage;