diff --git a/.claude/ARCHITECTURE.md b/.claude/ARCHITECTURE.md new file mode 100644 index 0000000..0b126a5 --- /dev/null +++ b/.claude/ARCHITECTURE.md @@ -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 diff --git a/.claude/DEPLOYMENT.md b/.claude/DEPLOYMENT.md new file mode 100644 index 0000000..ae618c3 --- /dev/null +++ b/.claude/DEPLOYMENT.md @@ -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). diff --git a/.claude/ENV.md b/.claude/ENV.md new file mode 100644 index 0000000..a1b370d --- /dev/null +++ b/.claude/ENV.md @@ -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 | diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0526ea8 --- /dev/null +++ b/.env.example @@ -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" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..2774e8d --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/re-pull.yml b/.github/workflows/re-pull.yml new file mode 100644 index 0000000..3ddf162 --- /dev/null +++ b/.github/workflows/re-pull.yml @@ -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 }} \ No newline at end of file diff --git a/.github/workflows/script/re-pull.sh b/.github/workflows/script/re-pull.sh new file mode 100644 index 0000000..1528e1d --- /dev/null +++ b/.github/workflows/script/re-pull.sh @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index c6e47ff..3fb6bb7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +.mcp/deploy-stg/node_modules /.pnp .pnp.js .yarn/install-state.gz diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..e2db79e --- /dev/null +++ b/.mcp.json @@ -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" + } + } + } +} diff --git a/.mcp/deploy-stg/bun.lock b/.mcp/deploy-stg/bun.lock new file mode 100644 index 0000000..f8a08f6 --- /dev/null +++ b/.mcp/deploy-stg/bun.lock @@ -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=="], + } +} diff --git a/.mcp/deploy-stg/package.json b/.mcp/deploy-stg/package.json new file mode 100644 index 0000000..36b84d9 --- /dev/null +++ b/.mcp/deploy-stg/package.json @@ -0,0 +1,9 @@ +{ + "name": "deploy-stg", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.11.0" + } +} diff --git a/.mcp/deploy-stg/server.ts b/.mcp/deploy-stg/server.ts new file mode 100644 index 0000000..c84ea4f --- /dev/null +++ b/.mcp/deploy-stg/server.ts @@ -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 { + 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); diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e0a9a91 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9023548 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/package.json b/package.json index c571a11..c7553a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sistem-desa-mandiri", - "version": "0.1.0", + "version": "0.1.7", "private": true, "scripts": { "dev": "next dev --experimental-https", diff --git a/prisma/migrations/20260306022915_deploy/migration.sql b/prisma/migrations/20260306022915_deploy/migration.sql new file mode 100644 index 0000000..80d912a --- /dev/null +++ b/prisma/migrations/20260306022915_deploy/migration.sql @@ -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; diff --git a/prisma/migrations/20260406074103_add_dummy_village/migration.sql b/prisma/migrations/20260406074103_add_dummy_village/migration.sql new file mode 100644 index 0000000..ad69505 --- /dev/null +++ b/prisma/migrations/20260406074103_add_dummy_village/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Village" ADD COLUMN "isDummy" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260430070147_auto/migration.sql b/prisma/migrations/20260430070147_auto/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/prisma/migrations/20260430070147_auto/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11fcbeb..c8c9f7f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,7 @@ model Village { name String desc String @db.Text isActive Boolean @default(true) + isDummy Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt Group Group[] diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 63bf431..55c4635 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -7,7 +7,7 @@ export async function POST(req: NextRequest) { const { phone }: ILogin = await req.json(); const user = await prisma.user.findUnique({ where: { phone, isActive: true }, - select: { id: true, phone: true, isWithoutOTP: true }, + select: { id: true, phone: true, isWithoutOTP: true, Village: { select: { isActive: true } } }, }); 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({ success: true, message: "Sukses", diff --git a/src/app/api/auth/otp/route.ts b/src/app/api/auth/otp/route.ts new file mode 100644 index 0000000..be04e33 --- /dev/null +++ b/src/app/api/auth/otp/route.ts @@ -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 }); + } +} diff --git a/src/app/api/mobile/division/report/route.ts b/src/app/api/mobile/division/report/route.ts index 873e0af..5b7ae59 100644 --- a/src/app/api/mobile/division/report/route.ts +++ b/src/app/api/mobile/division/report/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/module/_global"; import { funGetUserById } from "@/module/auth"; import _, { ceil } from "lodash"; import { NextResponse } from "next/server"; +import moment from "moment"; export async function GET(request: Request) { try { @@ -38,10 +39,10 @@ export async function GET(request: Request) { DivisionProjectTask: { some: { dateStart: { - gte: new Date(String(date)) + gte: moment(String(date)).startOf('day').toDate() }, dateEnd: { - lte: new Date(String(dateAkhir)) + lte: moment(String(dateAkhir)).endOf('day').toDate() } } } @@ -54,10 +55,10 @@ export async function GET(request: Request) { DivisionProjectTask: { some: { dateStart: { - gte: new Date(String(date)) + gte: moment(String(date)).startOf('day').toDate() }, dateEnd: { - lte: new Date(String(dateAkhir)) + lte: moment(String(dateAkhir)).endOf('day').toDate() } } } @@ -102,10 +103,10 @@ export async function GET(request: Request) { DivisionProjectTask: { some: { dateStart: { - gte: new Date(String(date)) + gte: moment(String(date)).startOf('day').toDate() }, dateEnd: { - lte: new Date(String(dateAkhir)) + lte: moment(String(dateAkhir)).endOf('day').toDate() } } } @@ -117,10 +118,10 @@ export async function GET(request: Request) { DivisionProjectTask: { some: { dateStart: { - gte: new Date(String(date)) + gte: moment(String(date)).startOf('day').toDate() }, 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) }, createdAt: { - gte: new Date(String(date)), - lte: new Date(String(dateAkhir)) + gte: moment(String(date)).startOf('day').toDate(), + lte: moment(String(dateAkhir)).endOf('day').toDate() }, } } else { @@ -181,8 +182,8 @@ export async function GET(request: Request) { category: 'FILE', idDivision: String(division), createdAt: { - gte: new Date(String(date)), - lte: new Date(String(dateAkhir)) + gte: moment(String(date)).startOf('day').toDate(), + lte: moment(String(dateAkhir)).endOf('day').toDate() }, } } @@ -252,8 +253,8 @@ export async function GET(request: Request) { DivisionCalendarReminder: { some: { dateStart: { - gte: new Date(String(date)), - lte: new Date() + gte: moment(String(date)).startOf('day').toDate(), + lte: moment().toDate() } } } @@ -267,8 +268,8 @@ export async function GET(request: Request) { DivisionCalendarReminder: { some: { dateStart: { - gt: new Date(), - lte: new Date(String(dateAkhir)) + gt: moment().toDate(), + lte: moment(String(dateAkhir)).endOf('day').toDate() } } } @@ -293,8 +294,8 @@ export async function GET(request: Request) { DivisionCalendarReminder: { some: { dateStart: { - gte: new Date(String(date)), - lte: new Date() + gte: moment(String(date)).startOf('day').toDate(), + lte: moment().toDate() } } } @@ -306,8 +307,8 @@ export async function GET(request: Request) { DivisionCalendarReminder: { some: { dateStart: { - gt: new Date(), - lte: new Date(String(dateAkhir)) + gt: moment().toDate(), + lte: moment(String(dateAkhir)).endOf('day').toDate() } } } diff --git a/src/app/api/mobile/user/[id]/route.ts b/src/app/api/mobile/user/[id]/route.ts index 4c058da..b993d8a 100644 --- a/src/app/api/mobile/user/[id]/route.ts +++ b/src/app/api/mobile/user/[id]/route.ts @@ -44,7 +44,8 @@ export async function GET(request: Request, context: { params: { id: string } }) }, Village:{ 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 role = users?.UserRole.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"]); diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts new file mode 100644 index 0000000..f3ebf0b --- /dev/null +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -0,0 +1,1537 @@ +import formatDateTime from "@/lib/formatDateTime"; +import timeAgo from "@/lib/timeAgo"; +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/monitoring +const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) + .use(cors({ + origin: "*", + methods: ["GET", "POST", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"] + })) + .use(swagger({ + path: "/docs", // Karena prefix instance adalah /api/monitoring, maka ini akan diakses di /api/monitoring/docs + documentation: { + info: { + title: "Des Plus - Monitoring API", + version: "1.0.0", + description: "API Khusus untuk kebutuhan Dashboard Monitoring", + } + } + })) + .onBeforeHandle(({ request, set, path }) => { + // Docs tidak perlu API key + if (path.startsWith("/api/monitoring/docs")) return; + + const apiKey = process.env.MONITORING_API_KEY; + const incoming = request.headers.get("x-api-key"); + + if (!apiKey || incoming !== apiKey) { + set.status = 401; + return { success: false, message: "Unauthorized" }; + } + }) + + .get("/grid-overview", async ({ query, set }) => { + try { + const version = await prisma.setting.findMany({ + select: { + id: true, + name: true, + value: true + } + }); + + const result_version = Object.fromEntries(version.map(item => [item.id, item.value])); + + const activity_today = await prisma.userLog.count({ + where: { + createdAt: { + gte: moment().subtract(1, 'days').toDate(), + lte: moment().toDate(), + } + } + }) + + const activity_yesterday = await prisma.userLog.count({ + where: { + createdAt: { + gte: moment().subtract(2, 'days').toDate(), + lte: moment().subtract(1, 'days').toDate(), + } + } + }) + + + const activity_increase = (activity_today - activity_yesterday); + const percentage_increase = (activity_increase / activity_yesterday) * 100 + + const total_village = await prisma.village.findMany({ + where: { + isDummy: false + } + }) + + const total_village_active = total_village.filter((item) => item.isActive).length + const total_village_inactive = total_village.filter((item) => !item.isActive).length + + return { + success: true, + message: "Berhasil mendapatkan data", + data: { + version: result_version, + activity: { + today: activity_today, + increase: _.isNaN(percentage_increase) ? 0 : percentage_increase.toFixed(2), + }, + village: { + active: total_village_active, + inactive: total_village_inactive, + }, + + }, + }; + } catch (error) { + console.error("[overview] grid-overview error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + detail: { + summary: "Grid Overview", + description: "Menu Overview - Mendapatkan daftar versi aplikasi.", + tags: ["overview"], + }, + } + ) + .get("/daily-activity", async ({ query, set }) => { + try { + // const data = await prisma.userLog.findMany({ + // where: { + // User: { + // Village: { + // isDummy: false + // } + // }, + // createdAt: { + // gte: moment().subtract(7, 'days').toDate(), + // lte: moment().toDate(), + // } + // }, + // select: { + // createdAt: true, + // } + // }) + + const data = await prisma.$queryRaw` + SELECT + DATE(ul."createdAt") AS tanggal, + COUNT(*) AS total + FROM "UserLog" ul + JOIN "User" u ON ul."idUser" = u."id" + JOIN "Village" v ON u."idVillage" = v."id" + WHERE v."isDummy" = false + AND ul."createdAt" >= NOW() - INTERVAL '7 days' + GROUP BY tanggal + ORDER BY tanggal;` as any[]; + + const result = []; + + // ubah data ke map biar gampang lookup + const map = data.reduce((acc: any, item: any) => { + const key = moment(item.tanggal).format('YYYY-MM-DD'); + acc[key] = Number(item.total); + return acc; + }, {}); + + // generate 7 hari terakhir + for (let i = 6; i >= 0; i--) { + const date = moment().subtract(i, 'days'); + + const key = date.format('YYYY-MM-DD'); + + result.push({ + date: date.format('DD MMM'), + logs: map[key] || 0 + }); + } + + return { + success: true, + message: "Berhasil mendapatkan data", + data: result, + }; + } catch (error) { + console.error("[overview] daily-activity error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + detail: { + summary: "Daily Activity", + description: "Menu Overview - Mendapatkan data grafik aktivitas harian semua desa.", + tags: ["overview"], + }, + } + ) + .get("/comparison-activity", async ({ query, set }) => { + try { + const villages = await prisma.village.findMany({ + where: { isDummy: false }, + select: { name: true }, + }); + + const data = await prisma.$queryRaw` + SELECT + v."name", + COUNT(ul."id") AS total_logs + FROM "UserLog" ul + JOIN "User" u ON ul."idUser" = u."id" + JOIN "Village" v ON u."idVillage" = v."id" + WHERE v."isDummy" = false + AND ul."createdAt" >= NOW() - INTERVAL '7 days' + GROUP BY v."id", v."name" + ORDER BY total_logs DESC; + ` as any[]; + + const logMap: Record = {}; + + data.forEach((item) => { + logMap[item.name] = Number(item.total_logs); + }); + + const result = villages.map((v) => ({ + village: v.name, + activity: logMap[v.name] || 0, + })); + + + return { + success: true, + message: "Berhasil mendapatkan data", + data: result, + }; + } catch (error) { + console.error("[overview] comparison-activity error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + detail: { + summary: "Comparison Activity", + description: "Menu Overview - Mendapatkan data grafik perbandingan aktivitas desa selama 7 hari terakhir.", + tags: ["overview"], + }, + } + ) + .post("/version-update", async ({ body, set }) => { + try { + const { mobile_latest_version, mobile_minimum_version, mobile_maintenance, mobile_message_update } = body + + await prisma.$transaction([ + prisma.setting.update({ + where: { id: "mobile_latest_version" }, + data: { value: mobile_latest_version }, + }), + prisma.setting.update({ + where: { id: "mobile_minimum_version" }, + data: { value: mobile_minimum_version }, + }), + prisma.setting.update({ + where: { id: "mobile_maintenance" }, + data: { value: mobile_maintenance.toString() }, + }), + prisma.setting.update({ + where: { id: "mobile_message_update" }, + data: { value: mobile_message_update }, + }), + ]); + + return { + success: true, + message: "Berhasil update data", + }; + } catch (error) { + console.error("[overview] version-update error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + }; + } + }, + { + body: t.Object({ + mobile_latest_version: t.String({ + error: "mobile latest version harus diisi", + description: "mobile latest version yang ingin diupdate" + }), + mobile_minimum_version: t.String({ + error: "mobile minimum version harus diisi", + description: "mobile minimum version yang ingin diupdate" + }), + mobile_maintenance: t.Boolean({ + description: "status maintenance mobile app" + }), + mobile_message_update: t.String({ + description: "pesan update mobile app" + }), + }), + detail: { + summary: "Version Update", + description: "Menu Overview - Mengupdate data versi aplikasi.", + tags: ["overview"], + }, + } + ) + .get("/get-villages", async ({ query, set }) => { + const { search, page } = query; + const pageNum = Number(page ?? 1); + try { + const data = await prisma.village.findMany({ + where: { + isDummy: false, + ...(search && { name: { contains: search, mode: 'insensitive' } }) + }, + select: { + id: true, + name: true, + isActive: true, + createdAt: true, + User: { + where: { + idUserRole: "supadmin" + }, + select: { + name: true, + }, + take: 1, + }, + }, + skip: (pageNum - 1) * 10, + take: 10, + }) + + const count = await prisma.village.count({ + where: { + isDummy: false, + ...(search && { name: { contains: search, mode: 'insensitive' } }) + }, + }) + + const result = data.map((village) => ({ + id: village.id, + name: village.name, + isActive: village.isActive, + createdAt: formatDateTime(village.createdAt), + perbekel: village.User[0]?.name || null, + })); + + return { + success: true, + message: "Berhasil mendapatkan data", + data: result, + totalPage: Math.ceil(count / 10), + currentPage: pageNum, + totalData: count, + }; + } catch (error) { + console.error("[villages] get-villages error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + search: t.Optional(t.String({ description: "Kata kunci pencarian nama desa" })), + page: t.Optional(t.String({ description: "Halaman data (default: 1)" })), + }), + detail: { + summary: "Get Villages", + description: "Menu Villages - Mendapatkan semua data desa.", + tags: ["villages"], + }, + } + ) + .post("/create-villages", async ({ body, set }) => { + const { name, desc, username, phone, nik, email, gender } = body; + try { + const create_village = await prisma.village.create({ + data: { + name: name, + desc: desc, + isDummy: false, + }, + select: { + id: true + } + }) + + if (create_village) { + const create_group = await prisma.group.create({ + data: { + idVillage: create_village.id, + name: "Dinas", + }, + select: { + id: true + } + }) + + const create_position = await prisma.position.create({ + data: { + idGroup: create_group.id, + name: "Perbekel", + }, + select: { + id: true + } + }) + + const cek_user = await prisma.user.count({ + where: { + OR: [ + { nik: nik }, + { phone: phone }, + { email: email }, + ] + }, + }); + + if (cek_user > 0) { + return { + success: true, + message: "Desa berhasil ditambahkan, namun user sudah terdaftar. Silahkan daftar user pada menu list user.", + }; + } + + const create_user = await prisma.user.create({ + data: { + idUserRole: "supadmin", + idVillage: create_village.id, + idGroup: create_group.id, + idPosition: create_position.id, + nik: nik, + name: username, + phone: phone, + email: email, + gender: gender + }, + select: { + id: true + } + }) + + if (create_user) { + return { + success: true, + message: "Desa dan user berhasil ditambahkan.", + }; + } else { + return { + success: true, + message: "Desa berhasil ditambahkan, namun user gagal ditambahkan. Silahkan daftar user pada menu list user.", + }; + } + } else { + return { + success: false, + message: "Gagal menambahkan data", + }; + } + } catch (error) { + console.error("[villages] create-villages error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + }; + } + }, + { + body: t.Object({ + name: t.String({ description: "Nama desa" }), + desc: t.String({ description: "Deskripsi desa" }), + username: t.String({ description: "Username" }), + phone: t.String({ description: "Nomor telepon" }), + nik: t.String({ description: "Nomor Induk Kependudukan" }), + email: t.String({ description: "Email" }), + gender: t.String({ description: "Jenis Kelamin" }), + }), + detail: { + summary: "Create Villages", + description: "Menu Villages - Membuat data desa.", + tags: ["villages"], + }, + } + ) + .get("/info-villages", async ({ query, set }) => { + const { id } = query; + try { + const data = await prisma.village.findUnique({ + where: { + id: id, + }, + select: { + id: true, + name: true, + isActive: true, + createdAt: true, + updatedAt: true, + desc: true, + User: { + where: { + idUserRole: "supadmin" + }, + select: { + name: true, + }, + take: 1, + }, + }, + }) + + if (!data) { + set.status = 404; + return { + success: false, + message: "Desa tidak ditemukan", + data: null, + }; + } + + const result = data ? { + id: data?.id, + name: data?.name, + isActive: data?.isActive, + desc: data?.desc, + createdAt: data?.createdAt ? formatDateTime(data.createdAt) : null, + updatedAt: data?.updatedAt ? formatDateTime(data.updatedAt) : null, + perbekel: data?.User[0]?.name || null, + } : null; + + return { + success: true, + message: "Berhasil mendapatkan data", + data: result, + }; + } catch (error) { + console.error("[detail-villages] info-villages error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + id: t.Optional(t.String({ description: "ID desa" })), + }), + detail: { + summary: "Info Villages", + description: "Menu Detail Villages - Mendapatkan info data desa untuk header dan kolom Informasi Sistem.", + tags: ["detail-villages"], + }, + } + ) + .get("/grid-villages", async ({ query, set }) => { + const { id } = query; + + try { + const village = await prisma.village.findUnique({ + where: { id: id } + }); + + if (!village) { + set.status = 404; + return { + success: false, + message: "Desa tidak ditemukan", + data: null, + }; + } + + const dataUser = await prisma.user.findMany({ + where: { + idVillage: id, + NOT: { + idUserRole: "developer" + } + } + }) + + const dataGroup = await prisma.group.findMany({ + where: { + idVillage: id, + } + }) + + const dataDivision = await prisma.division.findMany({ + where: { + idVillage: id, + } + }) + + const dataProject = await prisma.project.findMany({ + where: { + idVillage: id + } + }) + + + const result = { + user: { + active: dataUser.filter((user) => user.isActive).length, + nonActive: dataUser.filter((user) => !user.isActive).length, + }, + group: { + active: dataGroup.filter((group) => group.isActive).length, + nonActive: dataGroup.filter((group) => !group.isActive).length, + }, + division: { + active: dataDivision.filter((division) => division.isActive).length, + nonActive: dataDivision.filter((division) => !division.isActive).length, + }, + project: { + active: dataProject.filter((project) => project.isActive).length, + nonActive: dataProject.filter((project) => !project.isActive).length, + } + }; + + return { + success: true, + message: "Berhasil mendapatkan data", + data: result, + }; + } catch (error) { + console.error("[detail-villages] grid-villages error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + id: t.Optional(t.String({ description: "ID desa" })), + }), + detail: { + summary: "Grid Villages", + description: "Menu Grid Villages - Mendapatkan info data desa untuk 4 grid untuk halaman detail desa.", + tags: ["detail-villages"], + }, + } + ) + .get("/graph-log-villages", async ({ query, set }) => { + const { id, time } = query; + + try { + const village = await prisma.village.findUnique({ + where: { id }, + }); + + if (!village) { + set.status = 404; + return { + success: false, + message: "Desa tidak ditemukan", + data: null, + }; + } + + const now = new Date(); + let startDate: Date; + + if (time === "daily") { + startDate = new Date(); + startDate.setDate(now.getDate() - 13); // 14 hari + } else if (time === "monthly") { + startDate = new Date(now.getFullYear(), 0, 1); // awal tahun + } else if (time === "yearly") { + startDate = new Date(now.getFullYear() - 4, 0, 1); // 5 tahun terakhir (opsional) + } else { + startDate = new Date(0); + } + + const dataLog = await prisma.userLog.findMany({ + where: { + createdAt: { + gte: startDate, + }, + User: { + idVillage: id, + }, + }, + select: { + createdAt: true, + }, + }); + + // ========================= + // 🔥 GROUPING + // ========================= + const map: Record = {}; + + dataLog.forEach((log) => { + const date = new Date(log.createdAt); + + let label = ""; + + if (time === "daily") { + label = date.toLocaleDateString("id-ID", { + day: "2-digit", + month: "short", + }); + } else if (time === "monthly") { + label = date.toLocaleDateString("id-ID", { + month: "short", + }); + } else if (time === "yearly") { + label = date.getFullYear().toString(); + } + + map[label] = (map[label] || 0) + 1; + }); + + // ========================= + // 🔥 FORMAT FINAL + // ========================= + let result: any[] = []; + + if (time === "daily") { + for (let i = 13; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + + const label = d.toLocaleDateString("id-ID", { + day: "2-digit", + month: "short", + }); + + result.push({ + label, + aktivitas: map[label] || 0, + }); + } + } else if (time === "monthly") { + const year = now.getFullYear(); + for (let m = 0; m <= 11; m++) { + const d = new Date(year, m, 1); + + const label = d.toLocaleDateString("id-ID", { + month: "short", + }); + + result.push({ + label, + aktivitas: map[label] || 0, + }); + } + } else if (time === "yearly") { + const years = Object.keys(map).map(Number); + + if (years.length === 0) { + const currentYear = new Date().getFullYear(); + + result = [ + { label: currentYear.toString(), aktivitas: 0 } + ]; + } else { + const minYear = Math.min(...years); + const maxYear = Math.max(...years); + + result = []; + + for (let y = minYear; y <= maxYear; y++) { + const label = y.toString(); + + result.push({ + label, + aktivitas: map[label] || 0, + }); + } + } + } + + return { + success: true, + message: "Berhasil mendapatkan data", + data: result, + }; + } catch (error) { + console.error("[graph-log-villages] error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + id: t.String({ description: "ID desa" }), + time: t.Enum( + { + daily: "daily", + monthly: "monthly", + yearly: "yearly", + }, + { + description: "Rentang waktu (daily = 14 hari, monthly = 1 tahun, yearly = per tahun)", + } + ), + }), + detail: { + summary: "Graph Log Villages", + description: + "Mendapatkan data grafik log aktivitas desa berdasarkan rentang waktu (harian, bulanan, tahunan)", + tags: ["detail-villages"], + }, + } + ) + .get("/list-group-villages", async ({ query, set }) => { + const { id } = query; + try { + const data = await prisma.group.findMany({ + where: { + idVillage: id, + }, + select: { + id: true, + name: true, + } + }) + + if (!data) { + set.status = 404; + return { + success: false, + message: "Desa tidak ditemukan", + data: null, + }; + } + + return { + success: true, + message: "Berhasil mendapatkan data", + data: data, + }; + } catch (error) { + console.error("[detail-villages] list-group-villages error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + id: t.Optional(t.String({ description: "ID desa" })), + }), + detail: { + summary: "List Group Villages", + description: "Menu Detail Villages - Mendapatkan list group untuk dropdown.", + tags: ["detail-villages"], + }, + } + ) + .get("/list-position-villages", async ({ query, set }) => { + const { id } = query; + try { + const data = await prisma.position.findMany({ + where: { + idGroup: id, + }, + select: { + id: true, + name: true, + } + }) + + if (!data) { + set.status = 404; + return { + success: false, + message: "Posisi tidak ditemukan", + data: null, + }; + } + + return { + success: true, + message: "Berhasil mendapatkan data", + data: data, + }; + } catch (error) { + console.error("[detail-villages] list-position-villages error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + id: t.Optional(t.String({ description: "ID group" })), + }), + detail: { + summary: "List Position Villages", + description: "Menu Detail Villages - Mendapatkan list jabatan untuk dropdown.", + tags: ["detail-villages"], + }, + } + ) + .get("/list-userrole-villages", async ({ query, set }) => { + try { + const data = await prisma.userRole.findMany({ + select: { + id: true, + name: true, + } + }) + + if (!data) { + set.status = 404; + return { + success: false, + message: "Role tidak ditemukan", + data: null, + }; + } + + return { + success: true, + message: "Berhasil mendapatkan data", + data: data, + }; + } catch (error) { + console.error("[detail-villages] list-userrole-villages error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + detail: { + summary: "List User Role", + description: "Menu Detail Villages - Mendapatkan list role untuk dropdown.", + tags: ["detail-villages"], + }, + } + ) + .post("/edit-villages", async ({ body, set }) => { + const { id, name, desc } = body; + + try { + const village = await prisma.village.findUnique({ + where: { id }, + }); + + if (!village) { + set.status = 404; + return { + success: false, + message: "Desa tidak ditemukan", + }; + } + + const upd = await prisma.village.update({ + where: { id }, + data: { + name, + desc, + }, + }); + + return { + success: true, + message: "Berhasil mengupdate data", + }; + } catch (error) { + console.error("[edit-villages] error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + }; + } + }, + { + body: t.Object({ + id: t.String({ description: "ID desa" }), + name: t.String({ description: "Nama desa" }), + desc: t.String({ description: "Deskripsi desa" }), + }), + detail: { + summary: "Edit Villages", + description: + "Mengupdate data desa", + tags: ["detail-villages"], + }, + } + ) + .post("/update-status-villages", async ({ body, set }) => { + const { id, active } = body; + + try { + const village = await prisma.village.findUnique({ + where: { id }, + }); + + if (!village) { + set.status = 404; + return { + success: false, + message: "Desa tidak ditemukan", + }; + } + + const upd = await prisma.village.update({ + where: { id }, + data: { + isActive: active, + }, + }); + + return { + success: true, + message: "Berhasil mengupdate data", + }; + } catch (error) { + console.error("[update-status-villages] error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + }; + } + }, + { + body: t.Object({ + id: t.String({ description: "ID desa" }), + active: t.Boolean({ description: "Status desa" }), + }), + detail: { + summary: "Update Status Villages", + description: + "Mengupdate status desa", + tags: ["detail-villages"], + }, + } + ) + .get("/log-all-villages", async ({ query, set }) => { + const { page = 1, search } = query; + const pageNum = Number(page) || 1; + const take = 15; + const skip = (pageNum - 1) * take; + + try { + const dataLog = await prisma.userLog.findMany({ + where: { + ...(search && { + OR: [ + { + User: { + name: { + contains: search, + mode: "insensitive", + }, + }, + }, + { + User: { + Village: { + name: { + contains: search, + mode: "insensitive", + }, + }, + }, + }, + ], + }), + }, + select: { + id: true, + createdAt: true, + action: true, + desc: true, + User: { + select: { + name: true, + Village: { + select: { + name: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + skip, + take, + }); + + const total = await prisma.userLog.count({ + where: { + ...(search && { + OR: [ + { + User: { + name: { + contains: search, + mode: "insensitive", + }, + }, + }, + { + User: { + Village: { + name: { + contains: search, + mode: "insensitive", + }, + }, + }, + }, + ], + }), + }, + }); + + const result = dataLog.map((item) => ({ + id: item.id, + createdAt: timeAgo(item.createdAt), + action: item.action, + desc: item.desc, + username: item.User.name, + village: item.User.Village.name, + })); + + + return { + success: true, + message: "Berhasil mendapatkan data", + data: { + log: result, + total, + totalPage: Math.ceil(total / take), + currentPage: pageNum, + }, + }; + } catch (error) { + console.error("[log-villages] error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + page: t.Optional(t.String({ description: "Halaman" })), + search: t.Optional(t.String({ description: "Pencarian" })), + }), + detail: { + summary: "Log Villages", + description: + "Mendapatkan data log aktivitas desa berdasarkan halaman dan pencarian", + tags: ["log-activity"], + }, + } + ) + .get("/user", async ({ query, set }) => { + const { page = 1, search } = query; + const pageNum = Number(page) || 1; + const take = 15; + const skip = (pageNum - 1) * take; + + try { + const data = await prisma.user.findMany({ + where: { + ...(search && { + OR: [ + { + name: { + contains: search, + mode: "insensitive", + }, + }, + { + phone: { + contains: search, + mode: "insensitive", + }, + }, + { + email: { + contains: search, + mode: "insensitive", + }, + }, + { + nik: { + contains: search, + mode: "insensitive", + }, + }, + { + Village: { + name: { + contains: search, + mode: "insensitive", + }, + }, + }, + { + idUserRole: search, + }, + ], + }), + }, + select: { + id: true, + name: true, + nik: true, + phone: true, + email: true, + isWithoutOTP: true, + isActive: true, + idUserRole: true, + idVillage: true, + idGroup: true, + idPosition: true, + gender: true, + UserRole: { + select: { + name: true, + }, + }, + Village: { + select: { + name: true, + }, + }, + Group: { + select: { + name: true, + }, + }, + Position: { + select: { + name: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + skip, + take, + }); + + const total = await prisma.user.count({ + where: { + ...(search && { + OR: [ + { + name: { + contains: search, + mode: "insensitive", + }, + }, + { + phone: { + contains: search, + mode: "insensitive", + }, + }, + { + email: { + contains: search, + mode: "insensitive", + }, + }, + { + nik: { + contains: search, + mode: "insensitive", + }, + }, + { + Village: { + name: { + contains: search, + mode: "insensitive", + }, + }, + }, + { + idUserRole: search, + }, + ], + }), + }, + }); + + const result = data.map((item) => ({ + id: item.id, + name: item.name, + nik: item.nik, + phone: item.phone, + email: item.email, + gender: item.gender, + isWithoutOTP: item.isWithoutOTP, + isActive: item.isActive, + role: item.UserRole?.name, + village: item.Village?.name, + group: item.Group?.name, + position: item.Position?.name, + idUserRole: item.idUserRole, + idVillage: item.idVillage, + idGroup: item.idGroup, + idPosition: item.idPosition, + })); + + return { + success: true, + message: "Berhasil mendapatkan data", + data: { + user: result, + total, + totalPage: Math.ceil(total / take), + currentPage: pageNum, + }, + }; + } catch (error) { + console.error("[user] error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + data: null, + }; + } + }, + { + query: t.Object({ + page: t.Optional(t.String({ description: "Halaman" })), + search: t.Optional(t.String({ description: "Pencarian" })), + }), + detail: { + summary: "User", + description: + "Mendapatkan data user berdasarkan halaman dan pencarian", + tags: ["user"], + }, + } + ) + .post("/create-user", async ({ body, set }) => { + const { name, nik, phone, email, gender, idUserRole, idVillage, idGroup, idPosition } = body; + + try { + const cekUser = await prisma.user.findFirst({ + where: { + OR: [ + { nik }, + { phone }, + { email }, + ], + }, + }); + + if (cekUser) { + return { + success: false, + message: "User sudah ada", + }; + } + + const user = await prisma.user.create({ + data: { + name, + nik, + phone, + email, + gender, + idUserRole, + idVillage, + idGroup, + idPosition, + }, + }); + + return { + success: true, + message: "Berhasil membuat user", + }; + } catch (error) { + console.error("[create-user] error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + }; + } + }, + { + body: t.Object({ + name: t.String({ description: "Nama" }), + nik: t.String({ description: "NIK" }), + phone: t.String({ description: "Nomor Telepon" }), + email: t.String({ description: "Email" }), + gender: t.String({ description: "Jenis Kelamin" }), + idUserRole: t.String({ description: "ID Role" }), + idVillage: t.String({ description: "ID Desa" }), + idGroup: t.String({ description: "ID Group" }), + idPosition: t.Optional(t.String({ description: "ID Posisi" })), + }), + detail: { + summary: "Create User", + description: + "Membuat user", + tags: ["user"], + }, + } + ) + .post("/edit-user", async ({ body, set }) => { + const { id, name, nik, phone, email, gender, idUserRole, idVillage, idGroup, idPosition, isActive, isWithoutOTP } = body; + + try { + const cekId = await prisma.user.findFirst({ + where: { + id, + }, + }); + + if (!cekId) { + return { + success: false, + message: "User tidak ditemukan", + }; + } + + const cekUser = await prisma.user.findFirst({ + where: { + id: { + not: id, + }, + OR: [ + { nik }, + { phone }, + { email }, + ], + }, + }); + + if (cekUser) { + return { + success: false, + message: "User sudah ada", + }; + } + + const user = await prisma.user.update({ + where: { + id, + }, + data: { + name, + nik, + phone, + email, + gender, + idUserRole, + idVillage, + idGroup, + idPosition, + isActive, + isWithoutOTP, + }, + }); + + return { + success: true, + message: "Berhasil mengedit user", + }; + } catch (error) { + console.error("[edit-user] error:", error); + set.status = 500; + return { + success: false, + message: "Terjadi kesalahan pada server", + }; + } + }, + { + body: t.Object({ + id: t.String({ description: "ID" }), + name: t.String({ description: "Nama" }), + nik: t.String({ description: "NIK" }), + phone: t.String({ description: "Nomor Telepon" }), + email: t.String({ description: "Email" }), + gender: t.String({ description: "Jenis Kelamin" }), + idUserRole: t.String({ description: "ID Role" }), + idVillage: t.String({ description: "ID Desa" }), + idGroup: t.String({ description: "ID Group" }), + idPosition: t.Optional(t.Union([t.String(), t.Null()], { description: "ID Posisi" })), + isActive: t.Boolean({ description: "Aktif" }), + isWithoutOTP: t.Boolean({ description: "Tanpa OTP" }), + }), + detail: { + summary: "Edit User", + description: + "Mengedit user", + tags: ["user"], + }, + } + ) + ; + +; + + +export const GET = MonitoringServer.handle; +export const POST = MonitoringServer.handle; +export const OPTIONS = MonitoringServer.handle; diff --git a/src/app/api/noc/[[...slug]]/route.ts b/src/app/api/noc/[[...slug]]/route.ts new file mode 100644 index 0000000..68df427 --- /dev/null +++ b/src/app/api/noc/[[...slug]]/route.ts @@ -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; diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index 2ae3ab5..f14d46d 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -1,10 +1,11 @@ import { NextResponse } from "next/server"; +import { version } from "../../../../package.json"; export async function GET(request: Request) { 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) { 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 }); } } diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..bb62d25 --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,41 @@ +"use client"; + +export default function GlobalError({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + +

Terjadi Kesalahan

+ + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f54068e..42cc313 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,8 @@ import '@mantine/notifications/styles.css'; import { Lato } from "next/font/google"; import { Toaster } from 'react-hot-toast'; +export const dynamic = 'force-dynamic'; + export const metadata = { title: "SISTEM DESA MANDIRI", description: "I have followed setup instructions carefully", diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..9e129d3 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,24 @@ +import { Box, Text, Button } from "@mantine/core"; +import Link from "next/link"; + +export default function NotFound() { + return ( + + + 404 - Halaman Tidak Ditemukan + + + + ); +} diff --git a/src/lib/formatDateTime.ts b/src/lib/formatDateTime.ts new file mode 100644 index 0000000..39d6aef --- /dev/null +++ b/src/lib/formatDateTime.ts @@ -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 \ No newline at end of file diff --git a/src/lib/timeAgo.ts b/src/lib/timeAgo.ts new file mode 100644 index 0000000..9695f78 --- /dev/null +++ b/src/lib/timeAgo.ts @@ -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 \ No newline at end of file diff --git a/src/module/auth/login/view/view_login.tsx b/src/module/auth/login/view/view_login.tsx index 774155f..7269ad5 100644 --- a/src/module/auth/login/view/view_login.tsx +++ b/src/module/auth/login/view/view_login.tsx @@ -5,7 +5,6 @@ import { useFocusTrap } from "@mantine/hooks"; import { useState } from "react"; import toast from "react-hot-toast"; import ViewVerification from "../../varification/view/view_verification"; - function ViewLogin() { const focusTrapRef = useFocusTrap() const textInfo = "Kami akan mengirimkan kode verifikasi melalui WhatsApp untuk mengonfirmasi nomor Anda."; @@ -34,23 +33,24 @@ function ViewLogin() { }) const cekLogin = await cek.json() if (cekLogin.success) { - const code = Math.floor(1000 + Math.random() * 9000) try { - const res = await fetch(`https://wa.wibudev.com/code?nom=${cekLogin.phone}&text=*DARMASABA*%0A%0A - JANGAN BERIKAN KODE RAHASIA ini kepada siapa pun TERMASUK PIHAK DARMASABA. Masukkan otentikasi: *${encodeURIComponent(code)}*`).then( - async (res) => { - if (res.status == 200) { - setValPhone(cekLogin.phone) - setOTP(code) - setUser(cekLogin.id) - setVerif(true) - toast.success('Kode verifikasi telah dikirim') - } else { - console.error(res.status) - toast.error('Internal Server Error') - } - } - ) + const res = await fetch('/api/auth/otp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ phone: isPhone }) + }) + const data = await res.json() + if (data.success) { + setValPhone(data.phone) + setOTP(data.otp) + setUser(data.id) + setVerif(true) + toast.success('Kode verifikasi telah dikirim') + } else { + toast.error(data.message || 'Gagal mengirim kode verifikasi') + } } catch (error) { console.error(error) toast.error('Internal Server Error') diff --git a/src/module/auth/varification/view/view_verification.tsx b/src/module/auth/varification/view/view_verification.tsx index 678d183..49f135b 100644 --- a/src/module/auth/varification/view/view_verification.tsx +++ b/src/module/auth/varification/view/view_verification.tsx @@ -15,19 +15,20 @@ export default function ViewVerification({ phone, otp, user }: IVerification) { async function onResend() { try { - const code = Math.floor(1000 + Math.random() * 9000) - const res = await fetch(`https://wa.wibudev.com/code?nom=${phone}&text=*DARMASABA*%0A%0A - JANGAN BERIKAN KODE RAHASIA ini kepada siapa pun TERMASUK PIHAK DARMASABA. Masukkan otentikasi: *${encodeURIComponent(code)}*`) - .then( - async (res) => { - if (res.status == 200) { - toast.success('Kode verifikasi telah dikirim') - setOTP(code) - } else { - toast.error('Internal Server Error') - } - } - ); + const res = await fetch('/api/auth/otp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ phone }) + }) + const data = await res.json() + if (data.success) { + toast.success('Kode verifikasi telah dikirim') + setOTP(data.otp) + } else { + toast.error(data.message || 'Gagal mengirim ulang kode') + } } catch (error) { console.error(error) toast.error('Internal Server Error') diff --git a/src/module/seeder/data/user.json b/src/module/seeder/data/user.json index 2be17b5..7f44b07 100644 --- a/src/module/seeder/data/user.json +++ b/src/module/seeder/data/user.json @@ -19,7 +19,7 @@ "idPosition": "pos_ketua_rt01", "nik": "3201010101010001", "name": "Juli Ningrum", - "phone": "081234567890", + "phone": "6281234567890", "email": "juliningrum@gmail.com", "gender": "F" }, @@ -31,7 +31,7 @@ "idPosition": "pos_sekretaris_rt01", "nik": "3201010101010002", "name": "Salwa Kusmawati", - "phone": "081234567891", + "phone": "6281234567891", "email": "salwakusmawati@gmail.com", "gender": "F" }, @@ -43,7 +43,7 @@ "idPosition": "pos_staff_rt01", "nik": "3201010101010005", "name": "Bakidin Wibowo", - "phone": "081234567894", + "phone": "6281234567894", "email": "bakidinwibowo@gmail.com", "gender": "M" }, @@ -55,7 +55,7 @@ "idPosition": "pos_staff_rt01", "nik": "3201010101010006", "name": "Jais Kurniawan", - "phone": "081234567895", + "phone": "6281234567895", "email": "jaiskurniawan@gmail.com", "gender": "M" }, @@ -67,7 +67,7 @@ "idPosition": "pos_staff_rt01", "nik": "3201010101010007", "name": "Safira Oktaviani S.I.Kom", - "phone": "081234567896", + "phone": "6281234567896", "email": "safiraoktaviani@gmail.com", "gender": "F" }, @@ -79,7 +79,7 @@ "idPosition": "pos_staff_rt01", "nik": "3201010101010008", "name": "Agus Setiawan", - "phone": "081234567897", + "phone": "6281234567897", "email": "agussetiawannn@gmail.com", "gender": "M" } diff --git a/src/pages/_error.tsx b/src/pages/_error.tsx new file mode 100644 index 0000000..59e2a51 --- /dev/null +++ b/src/pages/_error.tsx @@ -0,0 +1,30 @@ +import { NextPageContext } from "next"; + +function ErrorPage({ statusCode }: { statusCode?: number }) { + return ( +
+

+ {statusCode === 404 + ? "404 - Halaman Tidak Ditemukan" + : "Terjadi Kesalahan"} +

+
+ ); +} + +ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => { + const statusCode = res ? res.statusCode : err ? err.statusCode : 404; + return { statusCode }; +}; + +export default ErrorPage;