From 2af22b4bc776ee0629a8b211d6cc4ec1536f3d8c Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Fri, 6 Mar 2026 10:21:40 +0800 Subject: [PATCH 01/46] build --- .github/workflows/publish.yml | 74 +++++++++++++++++++++++++++++++ Dockerfile | 83 +++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 Dockerfile diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..190e112 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,74 @@ +name: Publish Docker to GHCR + +on: + workflow_dispatch: + inputs: + environment: + description: "Target environment" + required: true + type: choice + default: "development" + options: + - development + - production + - staging + tag: + description: "Image tag (e.g. v1.0.0)" + required: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + publish: + name: Build & Push to GHCR (${{ github.event.inputs.environment }}) + 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.environment }}-${{ github.event.inputs.tag }} + type=raw,value=${{ github.event.inputs.environment }}-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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a3de2e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,83 @@ +# ============================== +# Stage 1: Builder (Bun) +# ============================== +FROM oven/bun:1-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 --ignore-scripts + +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-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 From a04e0186a281ef445c09c0ee7c8dec2e76bbfe81 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Fri, 6 Mar 2026 10:33:22 +0800 Subject: [PATCH 02/46] update env example --- .env.example | 52 ++ .../20260306022915_deploy/migration.sql | 879 ++++++++++++++++++ prisma/migrations/migration_lock.toml | 3 + 3 files changed, 934 insertions(+) create mode 100644 .env.example create mode 100644 prisma/migrations/20260306022915_deploy/migration.sql create mode 100644 prisma/migrations/migration_lock.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4cb7032 --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# =========================================== +# 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="your-vapid-public-key" + +# VAPID private key (keep secret, server-side only) +VAPID_PRIVATE_KEY="your-vapid-private-key" + +# =========================================== +# FILE STORAGE / WEBSOCKET API +# =========================================== +# API key for file operations (upload, delete, copy, view directory) +WS_APIKEY="your-websocket-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/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/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 From 069174cba11c58b3a33c8b6dec562524cd3db7db Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Fri, 6 Mar 2026 10:42:45 +0800 Subject: [PATCH 03/46] update env example dummy --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4cb7032..bdd6950 100644 --- a/.env.example +++ b/.env.example @@ -31,10 +31,10 @@ 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="your-vapid-public-key" +NEXT_PUBLIC_VAPID_PUBLIC_KEY="BJlglqrIZCbPCZyUs8UIzEP1Wi18hzvGaC3-KPLkQuoCV_EOKdyGJNbu7fs5jYaO571ipVAMko8YiwIMa1VjQEg" # VAPID private key (keep secret, server-side only) -VAPID_PRIVATE_KEY="your-vapid-private-key" +VAPID_PRIVATE_KEY="UHDY8M3-0beVIA2kt2zL3ZeMStJ0j6zVkVd2Cfqpgrc" # =========================================== # FILE STORAGE / WEBSOCKET API From 4abaa97cc0ae671541c6e0f1ba913ea1155f3ce2 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Fri, 6 Mar 2026 10:59:14 +0800 Subject: [PATCH 04/46] upd bun --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3a3de2e..86a24b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ============================== # Stage 1: Builder (Bun) # ============================== -FROM oven/bun:1-debian AS builder +FROM oven/bun:1.3.1-debian AS builder WORKDIR /app @@ -45,7 +45,7 @@ RUN bun run build # ============================== # Stage 2: Runner (Bun) # ============================== -FROM oven/bun:1-debian AS runner +FROM oven/bun:1.3.1-debian AS runner WORKDIR /app From b7063d3658864596bba79d59a4c1943b5daf1f9c Mon Sep 17 00:00:00 2001 From: bipproduction Date: Fri, 6 Mar 2026 11:12:24 +0800 Subject: [PATCH 05/46] fix: update Dockerfile bun version and remove --ignore-scripts Upgrade bun 1.3.1 to 1.3.6 and remove --ignore-scripts flag to fix prerender error during Docker build (null useContext dispatcher). Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 86a24b4..9023548 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ============================== # Stage 1: Builder (Bun) # ============================== -FROM oven/bun:1.3.1-debian AS builder +FROM oven/bun:1.3.6-debian AS builder WORKDIR /app @@ -19,7 +19,7 @@ ENV ONNXRUNTIME_NODE_INSTALL_CUDA=0 ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1 ENV NEXT_TELEMETRY_DISABLED=1 -RUN bun install --ignore-scripts +RUN bun install COPY . . @@ -45,7 +45,7 @@ RUN bun run build # ============================== # Stage 2: Runner (Bun) # ============================== -FROM oven/bun:1.3.1-debian AS runner +FROM oven/bun:1.3.6-debian AS runner WORKDIR /app From 5e7eb20c26b3ef7f8ed71fbf287f4d20150d890e Mon Sep 17 00:00:00 2001 From: bipproduction Date: Fri, 6 Mar 2026 11:19:36 +0800 Subject: [PATCH 06/46] fix: force dynamic rendering to skip static prerendering Add `export const dynamic = 'force-dynamic'` to root layout so all pages are rendered at request time instead of build time. Fixes useContext null error during Docker build prerendering. Co-Authored-By: Claude Opus 4.6 --- src/app/layout.tsx | 2 ++ 1 file changed, 2 insertions(+) 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", From 5230a319421fe63e3d60757a91ea24549523ec22 Mon Sep 17 00:00:00 2001 From: bipproduction Date: Fri, 6 Mar 2026 11:28:37 +0800 Subject: [PATCH 07/46] fix: add custom 404 and global error pages for Docker build Create not-found.tsx and global-error.tsx to prevent Next.js from falling back to legacy Pages Router _error pages during static generation, which fail with useContext null in Docker. Co-Authored-By: Claude Opus 4.6 --- src/app/global-error.tsx | 41 ++++++++++++++++++++++++++++++++++++++++ src/app/not-found.tsx | 24 +++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/app/global-error.tsx create mode 100644 src/app/not-found.tsx 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/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 + + + + ); +} From d401ebb208d2e48135d74d1a32f15dacc6c676a0 Mon Sep 17 00:00:00 2001 From: bipproduction Date: Fri, 6 Mar 2026 11:35:48 +0800 Subject: [PATCH 08/46] fix: add custom Pages Router _error page for 404/500 prerendering Override default Next.js _error page that imports from next/document, which fails during Docker build prerendering. Co-Authored-By: Claude Opus 4.6 --- src/pages/_error.tsx | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/pages/_error.tsx 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; From aba7a4c8fc135fadda8981f526b4fc3d8b9b3d4e Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Mon, 9 Mar 2026 10:35:37 +0800 Subject: [PATCH 09/46] update workflow --- .github/workflows/publish.yml | 22 +++---- .github/workflows/re-pull.yml | 37 ++++++++++++ .github/workflows/script/re-pull.sh | 93 +++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/re-pull.yml create mode 100644 .github/workflows/script/re-pull.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 190e112..2774e8d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,18 +3,20 @@ name: Publish Docker to GHCR on: workflow_dispatch: inputs: - environment: - description: "Target environment" + stack_env: + description: "stack env" required: true type: choice - default: "development" + default: "dev" options: - - development - - production - - staging + - dev + - prod + - stg tag: - description: "Image tag (e.g. v1.0.0)" + description: "Image tag (e.g. 1.0.0)" required: true + default: "1.0.0" + env: REGISTRY: ghcr.io @@ -22,7 +24,7 @@ env: jobs: publish: - name: Build & Push to GHCR (${{ github.event.inputs.environment }}) + name: Build & Push to GHCR ${{ github.repository }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }} runs-on: ubuntu-latest permissions: contents: read @@ -59,8 +61,8 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=raw,value=${{ github.event.inputs.environment }}-${{ github.event.inputs.tag }} - type=raw,value=${{ github.event.inputs.environment }}-latest + 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 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..8097813 --- /dev/null +++ b/.github/workflows/script/re-pull.sh @@ -0,0 +1,93 @@ +#!/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" != "200" ]; then + echo "❌ Redeploy gagal! HTTP Status: $HTTP_STATUS" + cat /tmp/portainer_response.json | jq . + exit 1 +fi + +echo "⏳ Menunggu container running..." + +MAX_RETRY=15 +COUNT=0 + +while [ $COUNT -lt $MAX_RETRY ]; do + sleep 5 + COUNT=$((COUNT + 1)) + + CONTAINERS=$(curl -s -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 * 5)) detik." +exit 1 \ No newline at end of file From 93e7f33f7c811fca1707b3cf88a1f652f61f8243 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Mon, 9 Mar 2026 10:36:31 +0800 Subject: [PATCH 10/46] update version --- src/app/api/version-app/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index 2ae3ab5..e8528e1 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; 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: "2.1.4", tahap: "beta", update: "-revisi api mobile pengumuman, diskusi umum dan diskusi divisi; -ditambah kan file " }, { 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 }); From 079395654d282e7444ba52a2c3af58d85e1e1f14 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Mon, 9 Mar 2026 11:34:13 +0800 Subject: [PATCH 11/46] update version dan data seeder --- src/app/api/version-app/route.ts | 2 +- src/module/seeder/data/user.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index e8528e1..3b5f624 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "2.1.4", tahap: "beta", update: "-revisi api mobile pengumuman, diskusi umum dan diskusi divisi; -ditambah kan file " }, { status: 200 }); + return NextResponse.json({ success: true, version: "2.1.6", tahap: "beta", update: "-revisi api mobile pengumuman, diskusi umum dan diskusi divisi; -ditambah kan file " }, { 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 }); 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" } From 1a20697f4cadd0aaebb3fe38ed4e80b34197adf2 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 11 Mar 2026 16:40:42 +0800 Subject: [PATCH 12/46] upd: api noc --- src/app/api/noc/[[...slug]]/route.ts | 503 +++++++++++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 src/app/api/noc/[[...slug]]/route.ts diff --git a/src/app/api/noc/[[...slug]]/route.ts b/src/app/api/noc/[[...slug]]/route.ts new file mode 100644 index 0000000..155449f --- /dev/null +++ b/src/app/api/noc/[[...slug]]/route.ts @@ -0,0 +1,503 @@ +import cors from "@elysiajs/cors"; +import { swagger } from "@elysiajs/swagger"; +import Elysia, { t } from "elysia"; +import { prisma } from "@/module/_global"; +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) => ({ + id: d.id, + division: d.name, + group: d.Group.name, + totalKegiatan: d._count.DivisionProject + })) + .sort((a, b) => 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: "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) => ({ + 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: "Mendapatkan daftar proyek umum terbaru dari berbagai grup pada desa tertentu.", + tags: ["NOC"], + }, + } + ) + + // ── GET /api/noc/village-summary ─────────────────────────────────────────── + .get( + "/village-summary", + async ({ query, set }) => { + const { idDesa } = query; + + if (!idDesa) { + set.status = 400; + return { success: false, message: "Parameter idDesa wajib diisi", data: null }; + } + + try { + const counts = await prisma.village.findUnique({ + where: { id: idDesa }, + select: { + name: true, + _count: { + select: { + User: true, + Group: true, + Division: true, + Project: true, + Announcement: true, + Discussion: true, + } + } + } + }); + + if (!counts) { + set.status = 404; + return { success: false, message: "Desa tidak ditemukan", data: null }; + } + + return { + success: true, + message: "Berhasil mendapatkan ringkasan desa", + data: { + idDesa, + namaDesa: counts.name, + summary: { + totalWarga: counts._count.User, + totalGrup: counts._count.Group, + totalDivisi: counts._count.Division, + totalProyek: counts._count.Project, + totalPengumuman: counts._count.Announcement, + totalDiskusi: counts._count.Discussion, + } + } + }; + } catch (error) { + console.error("[NOC] village-summary error:", error); + set.status = 500; + return { success: false, message: "Terjadi kesalahan pada server", data: null }; + } + }, + { + query: t.Object({ idDesa: t.String() }), + detail: { summary: "Village Summary Statistics", tags: ["NOC"] } + } + ) + + // ── GET /api/noc/recent-activity ─────────────────────────────────────────── + .get( + "/recent-activity", + 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 ?? 10), 50); + + try { + const logs = await prisma.userLog.findMany({ + where: { + User: { + idVillage: idDesa + } + }, + select: { + id: true, + action: true, + desc: true, + createdAt: true, + User: { + select: { + name: true, + img: true, + Group: { select: { name: true } } + } + } + }, + orderBy: { createdAt: "desc" }, + take: maxResults + }); + + const mapped = logs.map(l => ({ + id: l.id, + userName: l.User.name, + userGroup: l.User.Group.name, + userImg: l.User.img, + action: l.action, + description: l.desc, + time: moment(l.createdAt).fromNow(), + date: moment(l.createdAt).format("YYYY-MM-DD HH:mm:ss") + })); + + return { + success: true, + message: "Berhasil mendapatkan aktivitas terbaru", + data: { idDesa, total: mapped.length, activities: mapped } + }; + } catch (error) { + console.error("[NOC] recent-activity error:", error); + set.status = 500; + return { success: false, message: "Terjadi kesalahan pada server", data: null }; + } + }, + { + query: t.Object({ + idDesa: t.String(), + limit: t.Optional(t.String()) + }), + detail: { summary: "Recent User Activity Logs", tags: ["NOC"] } + } + ) + + // ── GET /api/noc/upcoming-events ─────────────────────────────────────────── + .get( + "/upcoming-events", + 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 ?? 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 mapped = events.map((e) => ({ + 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, + }, + })); + + return { + success: true, + message: "Berhasil mendapatkan upcoming events", + data: { + idDesa: village.id, + namaDesa: village.name, + total: mapped.length, + events: mapped, + }, + }; + } 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)" }) + ), + }), + detail: { + summary: "Upcoming Events", + description: "Mendapatkan daftar event yang akan datang untuk semua divisi pada desa tertentu.", + tags: ["NOC"], + }, + } + ); + +export const GET = NocServer.handle; +export const POST = NocServer.handle; From 339b1e25cce9e8d26482692e50f8c984c852126a Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 12 Mar 2026 16:08:42 +0800 Subject: [PATCH 13/46] upd: api noc --- src/app/api/noc/[[...slug]]/route.ts | 472 +++++++++++++++++++-------- 1 file changed, 328 insertions(+), 144 deletions(-) diff --git a/src/app/api/noc/[[...slug]]/route.ts b/src/app/api/noc/[[...slug]]/route.ts index 155449f..6db08d7 100644 --- a/src/app/api/noc/[[...slug]]/route.ts +++ b/src/app/api/noc/[[...slug]]/route.ts @@ -1,7 +1,8 @@ +import { prisma } from "@/module/_global"; import cors from "@elysiajs/cors"; import { swagger } from "@elysiajs/swagger"; import Elysia, { t } from "elysia"; -import { prisma } from "@/module/_global"; +import _ from "lodash"; import moment from "moment"; import "moment/locale/id"; @@ -11,7 +12,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) origin: "*", methods: ["GET", "POST", "OPTIONS"], })) - .use(swagger({ + .use(swagger({ path: "/docs", // Karena prefix instance adalah /api/noc, maka ini akan diakses di /api/noc/docs documentation: { info: { @@ -231,141 +232,11 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) } ) - // ── GET /api/noc/village-summary ─────────────────────────────────────────── - .get( - "/village-summary", - async ({ query, set }) => { - const { idDesa } = query; - - if (!idDesa) { - set.status = 400; - return { success: false, message: "Parameter idDesa wajib diisi", data: null }; - } - - try { - const counts = await prisma.village.findUnique({ - where: { id: idDesa }, - select: { - name: true, - _count: { - select: { - User: true, - Group: true, - Division: true, - Project: true, - Announcement: true, - Discussion: true, - } - } - } - }); - - if (!counts) { - set.status = 404; - return { success: false, message: "Desa tidak ditemukan", data: null }; - } - - return { - success: true, - message: "Berhasil mendapatkan ringkasan desa", - data: { - idDesa, - namaDesa: counts.name, - summary: { - totalWarga: counts._count.User, - totalGrup: counts._count.Group, - totalDivisi: counts._count.Division, - totalProyek: counts._count.Project, - totalPengumuman: counts._count.Announcement, - totalDiskusi: counts._count.Discussion, - } - } - }; - } catch (error) { - console.error("[NOC] village-summary error:", error); - set.status = 500; - return { success: false, message: "Terjadi kesalahan pada server", data: null }; - } - }, - { - query: t.Object({ idDesa: t.String() }), - detail: { summary: "Village Summary Statistics", tags: ["NOC"] } - } - ) - - // ── GET /api/noc/recent-activity ─────────────────────────────────────────── - .get( - "/recent-activity", - 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 ?? 10), 50); - - try { - const logs = await prisma.userLog.findMany({ - where: { - User: { - idVillage: idDesa - } - }, - select: { - id: true, - action: true, - desc: true, - createdAt: true, - User: { - select: { - name: true, - img: true, - Group: { select: { name: true } } - } - } - }, - orderBy: { createdAt: "desc" }, - take: maxResults - }); - - const mapped = logs.map(l => ({ - id: l.id, - userName: l.User.name, - userGroup: l.User.Group.name, - userImg: l.User.img, - action: l.action, - description: l.desc, - time: moment(l.createdAt).fromNow(), - date: moment(l.createdAt).format("YYYY-MM-DD HH:mm:ss") - })); - - return { - success: true, - message: "Berhasil mendapatkan aktivitas terbaru", - data: { idDesa, total: mapped.length, activities: mapped } - }; - } catch (error) { - console.error("[NOC] recent-activity error:", error); - set.status = 500; - return { success: false, message: "Terjadi kesalahan pada server", data: null }; - } - }, - { - query: t.Object({ - idDesa: t.String(), - limit: t.Optional(t.String()) - }), - detail: { summary: "Recent User Activity Logs", tags: ["NOC"] } - } - ) - // ── GET /api/noc/upcoming-events ─────────────────────────────────────────── .get( "/upcoming-events", async ({ query, set }) => { - const { idDesa, limit } = query; + const { idDesa, limit, filter } = query; if (!idDesa) { set.status = 400; @@ -443,7 +314,8 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) take: maxResults, }); - const mapped = events.map((e) => ({ + const todayMoment = moment().startOf("day"); + const mapper = (e: any) => ({ id: e.id, idCalendar: e.idCalendar, title: e.DivisionCalendar.title, @@ -462,17 +334,29 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) id: e.Division.id, name: e.Division.name, }, - })); + }); + + const todayEvents = events.filter(e => moment(e.dateStart).isSame(todayMoment, 'day')).map(mapper); + const upcomingEvents = events.filter(e => 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 upcoming events", - data: { - idDesa: village.id, - namaDesa: village.name, - total: mapped.length, - events: mapped, - }, + message: "Berhasil mendapatkan events", + data: data, }; } catch (error) { console.error("[NOC] upcoming-events error:", error); @@ -490,14 +374,314 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) 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: "Upcoming Events", - description: "Mendapatkan daftar event yang akan datang untuk semua divisi pada desa tertentu.", + summary: "Events (Today & Upcoming)", + description: "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: "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, { _count }) => 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: "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: "Mendapatkan latest discussion pada desa tertentu.", tags: ["NOC"], }, } ); + export const GET = NocServer.handle; export const POST = NocServer.handle; From 270875a95c07ee7737430e693f52340babe40c59 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Mon, 16 Mar 2026 10:39:23 +0800 Subject: [PATCH 14/46] upd: api version --- src/app/api/version-app/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index 3b5f624..027034b 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "2.1.6", tahap: "beta", update: "-revisi api mobile pengumuman, diskusi umum dan diskusi divisi; -ditambah kan file " }, { status: 200 }); + return NextResponse.json({ success: true, version: "2.1.7", tahap: "beta", update: "-api untuk dashboard noc" }, { 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 }); From d1f553ee32776d0477214e24cdda201ff48e2c8f Mon Sep 17 00:00:00 2001 From: amal Date: Wed, 25 Mar 2026 17:02:26 +0800 Subject: [PATCH 15/46] upd: api noc --- src/app/api/noc/[[...slug]]/route.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/api/noc/[[...slug]]/route.ts b/src/app/api/noc/[[...slug]]/route.ts index 6db08d7..68df427 100644 --- a/src/app/api/noc/[[...slug]]/route.ts +++ b/src/app/api/noc/[[...slug]]/route.ts @@ -84,13 +84,13 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) // Hitung total kegiatan per divisi & urutkan descending, ambil top sesuai limit const ranked = divisions - .map((d) => ({ + .map((d: any) => ({ id: d.id, division: d.name, group: d.Group.name, totalKegiatan: d._count.DivisionProject })) - .sort((a, b) => b.totalKegiatan - a.totalKegiatan) + .sort((a: any, b: any) => b.totalKegiatan - a.totalKegiatan) .slice(0, maxResults); return { @@ -119,7 +119,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) }), detail: { summary: "Divisi Teraktif", - description: "Mendapatkan daftar divisi teraktif berdasarkan jumlah proyek pada desa tertentu.", + description: "Menu Beranda - Mendapatkan daftar divisi teraktif berdasarkan jumlah proyek pada desa tertentu.", tags: ["NOC"], }, } @@ -187,7 +187,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) take: maxResults, }); - const mapped = projects.map((p) => ({ + const mapped = projects.map((p: any) => ({ id: p.id, title: p.title, status: p.status, @@ -226,7 +226,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) }), detail: { summary: "Latest Projects General", - description: "Mendapatkan daftar proyek umum terbaru dari berbagai grup pada desa tertentu.", + description: "Menu kinerja divisi - Mendapatkan daftar proyek umum terbaru dari berbagai grup pada desa tertentu.", tags: ["NOC"], }, } @@ -336,8 +336,8 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) }, }); - const todayEvents = events.filter(e => moment(e.dateStart).isSame(todayMoment, 'day')).map(mapper); - const upcomingEvents = events.filter(e => moment(e.dateStart).isAfter(todayMoment, 'day')).map(mapper); + 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, @@ -380,7 +380,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) }), detail: { summary: "Events (Today & Upcoming)", - description: "Mendapatkan daftar event pada hari ini dan yang akan datang untuk semua divisi pada desa tertentu.", + description: "Menu beranda dan kinerja divisi - Mendapatkan daftar event pada hari ini dan yang akan datang untuk semua divisi pada desa tertentu.", tags: ["NOC"], }, } @@ -489,7 +489,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) }), detail: { summary: "Diagram Jumlah Document", - description: "Mendapatkan diagram jumlah document pada desa tertentu.", + description: "Menu kinerja divisi - Mendapatkan diagram jumlah document pada desa tertentu.", tags: ["NOC"], }, } @@ -543,7 +543,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) 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, { _count }) => n + _count, 0)).toFixed(2) + 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 + '%', @@ -581,7 +581,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) }), detail: { summary: "Diagram Progres Kegiatan", - description: "Mendapatkan diagram progres kegiatan pada desa tertentu.", + description: "Menu kinerja divisi - Mendapatkan diagram progres kegiatan pada desa tertentu.", tags: ["NOC"], }, } @@ -676,7 +676,7 @@ const NocServer = new Elysia({ prefix: "/api/noc" }) }), detail: { summary: "Latest Discussion", - description: "Mendapatkan latest discussion pada desa tertentu.", + description: "Menu kinerja divisi - Mendapatkan latest discussion pada desa tertentu.", tags: ["NOC"], }, } From eaa1a74290af56da9cd8e63fa0c760e222485105 Mon Sep 17 00:00:00 2001 From: amal Date: Fri, 27 Mar 2026 14:07:35 +0800 Subject: [PATCH 16/46] upd: url otp --- src/app/api/auth/otp/route.ts | 59 +++++++++++++++++++ src/app/api/version-app/route.ts | 2 +- src/module/auth/login/view/view_login.tsx | 34 +++++------ .../varification/view/view_verification.tsx | 27 +++++---- 4 files changed, 91 insertions(+), 31 deletions(-) create mode 100644 src/app/api/auth/otp/route.ts 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/version-app/route.ts b/src/app/api/version-app/route.ts index 027034b..6319559 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "2.1.7", tahap: "beta", update: "-api untuk dashboard noc" }, { status: 200 }); + return NextResponse.json({ success: true, version: "2.1.8", tahap: "beta", update: "-api untuk dashboard noc; -perbaikan otp" }, { 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 }); 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') From 0b9f07e5434b77d97fce646dd1f4622d392d0249 Mon Sep 17 00:00:00 2001 From: amal Date: Mon, 6 Apr 2026 17:23:32 +0800 Subject: [PATCH 17/46] upd: api monitoring --- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/app/api/monitoring/[[...slug]]/route.ts | 233 ++++++++++++++++++ src/app/api/version-app/route.ts | 2 +- 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260406074103_add_dummy_village/migration.sql create mode 100644 src/app/api/monitoring/[[...slug]]/route.ts 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/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/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts new file mode 100644 index 0000000..8845e15 --- /dev/null +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -0,0 +1,233 @@ +import { prisma } from "@/module/_global"; +import cors from "@elysiajs/cors"; +import { swagger } from "@elysiajs/swagger"; +import Elysia 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"], + })) + .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", + } + } + })) + + .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 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 result = data.map(item => ({ + village: item.name, + activity: Number(item.total_logs) + })); + + + 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"], + }, + } + ) + + ; + + +export const GET = MonitoringServer.handle; +export const POST = MonitoringServer.handle; diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index 6319559..54962f4 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "2.1.8", tahap: "beta", update: "-api untuk dashboard noc; -perbaikan otp" }, { status: 200 }); + return NextResponse.json({ success: true, version: "2.1.9", tahap: "beta", update: "-api untuk dashboard monitoring" }, { 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 }); From cb565ba0bd36664b86f7dbd73f56c9b40e241caf Mon Sep 17 00:00:00 2001 From: amal Date: Tue, 7 Apr 2026 14:52:46 +0800 Subject: [PATCH 18/46] upd: api monitoring menu desa --- src/app/api/monitoring/[[...slug]]/route.ts | 68 ++++++++++++++++++++- src/lib/formatDateTime.ts | 11 ++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/lib/formatDateTime.ts diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 8845e15..ee629c8 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -1,7 +1,8 @@ +import formatDateTime from "@/lib/formatDateTime"; import { prisma } from "@/module/_global"; import cors from "@elysiajs/cors"; import { swagger } from "@elysiajs/swagger"; -import Elysia from "elysia"; +import Elysia, { t } from "elysia"; import _ from "lodash"; import moment from "moment"; import "moment/locale/id"; @@ -225,6 +226,71 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) }, } ) + .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 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, + }; + } 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"], + }, + } + ) ; 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 From 5fd5c15394bd0837be5a4643071b61e6cb8faa99 Mon Sep 17 00:00:00 2001 From: amal Date: Tue, 7 Apr 2026 17:25:14 +0800 Subject: [PATCH 19/46] upd: api monitoring detail desa --- src/app/api/monitoring/[[...slug]]/route.ts | 331 +++++++++++++++++++- 1 file changed, 330 insertions(+), 1 deletion(-) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index ee629c8..12c7fa5 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -291,8 +291,337 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) }, } ) + .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, + 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, + createdAt: data?.createdAt ? formatDateTime(data.createdAt) : 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("/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("[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: "Log Villages", + description: + "Mendapatkan data log aktivitas desa berdasarkan rentang waktu (harian, bulanan, tahunan)", + tags: ["detail-villages"], + }, + } + ); + +; export const GET = MonitoringServer.handle; From 93ae77d3359983cf928d167f6558474be043c3f4 Mon Sep 17 00:00:00 2001 From: amal Date: Wed, 8 Apr 2026 14:50:12 +0800 Subject: [PATCH 20/46] upd: api monitoring log activity --- src/app/api/monitoring/[[...slug]]/route.ts | 134 +++++++++++++++++++- src/lib/timeAgo.ts | 38 ++++++ 2 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 src/lib/timeAgo.ts diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 12c7fa5..63a79ef 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -1,4 +1,5 @@ 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"; @@ -452,7 +453,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) }, } ) - .get("/log-villages", async ({ query, set }) => { + .get("/graph-log-villages", async ({ query, set }) => { const { id, time } = query; try { @@ -589,7 +590,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) data: result, }; } catch (error) { - console.error("[log-villages] error:", error); + console.error("[graph-log-villages] error:", error); set.status = 500; return { success: false, @@ -612,11 +613,136 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) } ), }), + detail: { + summary: "Graph Log Villages", + description: + "Mendapatkan data grafik log aktivitas desa berdasarkan rentang waktu (harian, bulanan, tahunan)", + 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 rentang waktu (harian, bulanan, tahunan)", - tags: ["detail-villages"], + "Mendapatkan data log aktivitas desa berdasarkan halaman dan pencarian", + tags: ["log-activity"], }, } ); 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 From 5efb96a92a09bab560c226dfdd2095814e3dfad9 Mon Sep 17 00:00:00 2001 From: amal Date: Wed, 8 Apr 2026 17:24:50 +0800 Subject: [PATCH 21/46] upd: api monitoring--user --- src/app/api/monitoring/[[...slug]]/route.ts | 1209 ++++++++++++++----- 1 file changed, 903 insertions(+), 306 deletions(-) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 63a79ef..9526706 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -25,77 +25,75 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) } })) - .get( - "/grid-overview", - async ({ query, set }) => { - try { - const version = await prisma.setting.findMany({ - select: { - id: true, - name: true, - value: true + .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 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_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 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 = 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, - }, + 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), }, - }; - } catch (error) { - console.error("[overview] grid-overview error:", error); - set.status = 500; - return { - success: false, - message: "Terjadi kesalahan pada server", - data: null, - }; - } - }, + 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", @@ -104,28 +102,26 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) }, } ) - .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, - // } - // }) + .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` + const data = await prisma.$queryRaw` SELECT DATE(ul."createdAt") AS tanggal, COUNT(*) AS total @@ -137,42 +133,42 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) GROUP BY tanggal ORDER BY tanggal;` as any[]; - const result = []; + 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; - }, {}); + // 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'); + // generate 7 hari terakhir + for (let i = 6; i >= 0; i--) { + const date = moment().subtract(i, 'days'); - const key = date.format('YYYY-MM-DD'); + 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, - }; + 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", @@ -181,11 +177,9 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) }, } ) - .get( - "/comparison-activity", - async ({ query, set }) => { - try { - const data = await prisma.$queryRaw` + .get("/comparison-activity", async ({ query, set }) => { + try { + const data = await prisma.$queryRaw` SELECT v."name", COUNT(ul."id") AS total_logs @@ -198,27 +192,27 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) ORDER BY total_logs DESC; ` as any[]; - const result = data.map(item => ({ - village: item.name, - activity: Number(item.total_logs) - })); + const result = data.map(item => ({ + village: item.name, + activity: Number(item.total_logs) + })); - 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, - }; - } - }, + 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", @@ -227,59 +221,117 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) }, } ) - .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, + .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, - }) + }, + skip: (pageNum - 1) * 10, + take: 10, + }) - const result = data.map((village) => ({ - id: village.id, - name: village.name, - isActive: village.isActive, - createdAt: formatDateTime(village.createdAt), - perbekel: village.User[0]?.name || null, - })); + 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, - }; - } catch (error) { - console.error("[villages] get-villages error:", error); - set.status = 500; - return { - success: false, - message: "Terjadi kesalahan pada server", - data: null, - }; - } - }, + return { + success: true, + message: "Berhasil mendapatkan data", + data: result, + }; + } 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" })), @@ -292,64 +344,174 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) }, } ) - .get( - "/info-villages", - async ({ query, set }) => { - const { id } = query; - try { - const data = await prisma.village.findUnique({ - where: { - id: id, + .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, - name: true, - isActive: true, - createdAt: true, - User: { - where: { - idUserRole: "supadmin" - }, - select: { - name: true, - }, - take: 1, - }, - }, + id: true + } }) - if (!data) { - set.status = 404; + 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: false, - message: "Desa tidak ditemukan", - data: null, + success: true, + message: "Desa berhasil ditambahkan, namun user sudah terdaftar. Silahkan daftar user pada menu list user.", }; } - const result = data ? { - id: data?.id, - name: data?.name, - isActive: data?.isActive, - createdAt: data?.createdAt ? formatDateTime(data.createdAt) : null, - perbekel: data?.User[0]?.name || null, - } : null; + 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 + } + }) - return { - success: true, - message: "Berhasil mendapatkan data", - data: result, - }; - } catch (error) { - console.error("[detail-villages] info-villages error:", error); - set.status = 500; + 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: "Terjadi kesalahan pada server", + 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, + 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, + createdAt: data?.createdAt ? formatDateTime(data.createdAt) : 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" })), @@ -361,87 +523,85 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) }, } ) - .get( - "/grid-villages", - async ({ query, set }) => { - const { id } = query; + .get("/grid-villages", async ({ query, set }) => { + const { id } = query; - try { - const village = await prisma.village.findUnique({ - where: { id: id } - }); + 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; + if (!village) { + set.status = 404; return { success: false, - message: "Terjadi kesalahan pada server", + 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" })), @@ -621,6 +781,106 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) }, } ) + .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; @@ -745,7 +1005,344 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) tags: ["log-activity"], }, } - ); + ) + .get("/user", async ({ query, set }) => { + const { page = 1, search } = query; + const pageNum = Number(page) || 1; + const take = 25; + 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, + 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, + isWithoutOTP: item.isWithoutOTP, + isActive: item.isActive, + role: item.UserRole?.name, + village: item.Village?.name, + group: item.Group?.name, + position: item.Position?.name, + })); + + 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.String({ description: "ID Posisi" })), + isActive: t.Boolean({ description: "Aktif" }), + isWithoutOTP: t.Boolean({ description: "Tanpa OTP" }), + }), + detail: { + summary: "Edit User", + description: + "Mengedit user", + tags: ["user"], + }, + } + ) + ; ; From 3c0a5639b661e3391f9b96fc4a026107668f95bb Mon Sep 17 00:00:00 2001 From: amal Date: Thu, 9 Apr 2026 17:33:21 +0800 Subject: [PATCH 22/46] upd : api monitoring --- src/app/api/monitoring/[[...slug]]/route.ts | 31 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 9526706..7ca0168 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -13,6 +13,7 @@ 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 @@ -179,6 +180,11 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) ) .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", @@ -192,9 +198,15 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) ORDER BY total_logs DESC; ` as any[]; - const result = data.map(item => ({ - village: item.name, - activity: Number(item.total_logs) + 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, })); @@ -309,6 +321,13 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) 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, @@ -321,6 +340,9 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) 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); @@ -1009,7 +1031,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) .get("/user", async ({ query, set }) => { const { page = 1, search } = query; const pageNum = Number(page) || 1; - const take = 25; + const take = 15; const skip = (pageNum - 1) * take; try { @@ -1349,3 +1371,4 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) export const GET = MonitoringServer.handle; export const POST = MonitoringServer.handle; +export const OPTIONS = MonitoringServer.handle; From d861a3ea8620beb6d0040cfba514d55a82e08d7f Mon Sep 17 00:00:00 2001 From: amal Date: Fri, 10 Apr 2026 13:44:15 +0800 Subject: [PATCH 23/46] upd: fx api monitoring --- src/app/api/monitoring/[[...slug]]/route.ts | 147 ++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 7ca0168..8d0d83e 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -803,6 +803,143 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) }, } ) + .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; @@ -1085,6 +1222,11 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) email: true, isWithoutOTP: true, isActive: true, + idUserRole: true, + idVillage: true, + idGroup: true, + idPosition: true, + gender: true, UserRole: { select: { name: true, @@ -1163,12 +1305,17 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) 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 { From ea3bf2cc3c1e8539729fc8004ccb7216caa53e68 Mon Sep 17 00:00:00 2001 From: amal Date: Mon, 13 Apr 2026 11:36:26 +0800 Subject: [PATCH 24/46] upd : api monitoring --- src/app/api/monitoring/[[...slug]]/route.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 8d0d83e..fb06cbb 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -490,6 +490,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) name: true, isActive: true, createdAt: true, + desc: true, User: { where: { idUserRole: "supadmin" @@ -515,6 +516,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) id: data?.id, name: data?.name, isActive: data?.isActive, + desc: data?.desc, createdAt: data?.createdAt ? formatDateTime(data.createdAt) : null, perbekel: data?.User[0]?.name || null, } : null; From 73b19e0dd16a5043912ec6fb4e4e1b7aa55d2a7a Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Fri, 17 Apr 2026 15:27:33 +0800 Subject: [PATCH 25/46] upd: fix route laporan divisi --- src/app/api/mobile/division/report/route.ts | 41 +++++++++++---------- src/app/api/version-app/route.ts | 2 +- 2 files changed, 22 insertions(+), 21 deletions(-) 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/version-app/route.ts b/src/app/api/version-app/route.ts index 54962f4..bebb874 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "2.1.9", tahap: "beta", update: "-api untuk dashboard monitoring" }, { status: 200 }); + return NextResponse.json({ success: true, version: "2.1.10", tahap: "beta", update: "-perbaikan grafik divisi" }, { 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 }); From 545e668befdd63f1ff0e8805ec4dcfa3298703b4 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Mon, 20 Apr 2026 17:28:12 +0800 Subject: [PATCH 26/46] upd: claude --- CLAUDE.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..378917c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# 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 + +**Sistem Desa Mandiri** is a village administration platform built on Next.js 14 (App Router) with PostgreSQL. + +### Key Layers + +- **`src/app/(application)/`** — Auth-protected pages grouped by feature (announcement, division, project, discussion, member, profile, home, group) +- **`src/app/(auth)/`** — Login/register pages +- **`src/app/api/`** — REST API endpoints; subdirectories map to resource types (`/api/announcement`, `/api/project`, `/api/task`, etc.). Mobile-specific endpoints live under `/api/mobile/` +- **`src/module/`** — Business logic modules, one per feature (19 modules). Each module contains hooks, components, and API call functions for that domain +- **`src/lib/`** — Shared utilities: Prisma client singleton (`prisma.ts`), Firebase init, route definitions (`routes.ts`), push notification hooks + +### Data Access Pattern + +All DB access goes through the Prisma client singleton in `src/lib/prisma.ts`. Prisma schema is at `prisma/schema.prisma` (40+ models). Migrations live in `prisma/migrations/`. + +### State Management + +- **Hookstate** (`@hookstate/core` + `@hookstate/localstored`) for client-side global state with localStorage persistence +- **Iron-session** for server-side session management / auth +- **Jose** for JWT handling + +### UI Stack + +- **Mantine 7** is the primary UI library (components, forms, modals, notifications, charts, dates, etc.) +- **Tailwind CSS** for utility classes — used alongside Mantine +- **PostCSS** configured with Mantine preset (`postcss.config.mjs`) + +### Real-time & Notifications + +- **Firebase FCM** (`src/lib/firebase/`) for mobile push notifications +- **Web Push + VAPID keys** (`src/lib/usePushNotifications.ts`) for browser push +- **wibu-realtime** (custom library) for WebSocket-based real-time updates + +### User Roles + +Five roles with distinct access levels (see `PANDUAN PENGGUNAAN.md`): +1. **Super Admin** — full system access +2. **Admin Desa** — village-level administration +3. **Ketua Divisi** — division leader +4. **Anggota Divisi** — division member +5. **Warga/Perangkat Desa** — village resident/official + +## Environment Variables + +Copy `.env.example` to `.env`. Required variables: + +| Variable | Purpose | +|---|---| +| `DATABASE_URL` | PostgreSQL connection string | +| `GOOGLE_PROJECT_ID`, `GOOGLE_CLIENT_EMAIL`, `GOOGLE_PRIVATE_KEY` | Firebase Admin SDK (FCM) | +| `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | Web Push | +| `WS_APIKEY` | WebSocket/file storage API key | +| `WIBU_REALTIME_KEY` | Real-time communication | +| `FCM_KEY` | Firebase Cloud Messaging | + +## Deployment + +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). From dd6f27cf2b2d29bbcf0fbed7646553417f42c6b4 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 21 Apr 2026 17:29:47 +0800 Subject: [PATCH 27/46] upd: update api monitoring --- src/app/api/monitoring/[[...slug]]/route.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index fb06cbb..84c62af 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -490,6 +490,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) name: true, isActive: true, createdAt: true, + updatedAt: true, desc: true, User: { where: { @@ -518,6 +519,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) 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; From 144f4d554a9f87aeacd666c219973ca48623caee Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 22 Apr 2026 16:43:05 +0800 Subject: [PATCH 28/46] upd: add village active check on login and mobile user api Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/auth/login/route.ts | 9 ++++++++- src/app/api/mobile/user/[id]/route.ts | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) 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/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"]); From 64590d9fba76c20b49dd55fbfa4ed0f2ad41258b Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 11:34:52 +0800 Subject: [PATCH 29/46] upd: version app --- src/app/api/version-app/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index bebb874..edf4934 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "2.1.10", tahap: "beta", update: "-perbaikan grafik divisi" }, { status: 200 }); + return NextResponse.json({ success: true, version: "2.2.0", 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 }); From 5cd35dd534c9d0770e07981e28f0fb2e1a230706 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 12:12:22 +0800 Subject: [PATCH 30/46] bump: version 0.1.1 --- package.json | 2 +- src/app/api/version-app/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c571a11..d300917 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sistem-desa-mandiri", - "version": "0.1.0", + "version": "0.1.1", "private": true, "scripts": { "dev": "next dev --experimental-https", diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index edf4934..93d47ca 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "2.2.0", tahap: "beta", update: "-perbaikan fitur diskusi dan perbaikan tampilan mobile" }, { status: 200 }); + return NextResponse.json({ success: true, version: "0.1.1", 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 }); From 7c37ae4ed8225055d531cd159060e445505551c4 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 12:14:05 +0800 Subject: [PATCH 31/46] bump: version 0.1.2 --- package.json | 2 +- src/app/api/version-app/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d300917..fdd4ff1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sistem-desa-mandiri", - "version": "0.1.1", + "version": "0.1.2", "private": true, "scripts": { "dev": "next dev --experimental-https", diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index 93d47ca..cc0a05e 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "0.1.1", tahap: "beta", update: "-perbaikan fitur diskusi dan perbaikan tampilan mobile" }, { status: 200 }); + return NextResponse.json({ success: true, version: "0.1.2", 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 }); From 43f7005d16a937b929158dff8e051b891c2f6e44 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 12:15:18 +0800 Subject: [PATCH 32/46] bump: version 0.1.3 --- package.json | 2 +- src/app/api/version-app/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index fdd4ff1..dd4e28e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sistem-desa-mandiri", - "version": "0.1.2", + "version": "0.1.3", "private": true, "scripts": { "dev": "next dev --experimental-https", diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index cc0a05e..48e75a9 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "0.1.2", tahap: "beta", update: "-perbaikan fitur diskusi dan perbaikan tampilan mobile" }, { status: 200 }); + return NextResponse.json({ success: true, version: "0.1.3", 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 }); From 58535ee7a67b5f1d871491da985352933f32d61d Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 12:17:27 +0800 Subject: [PATCH 33/46] bump: version 0.1.4 --- package.json | 2 +- src/app/api/version-app/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index dd4e28e..147e499 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sistem-desa-mandiri", - "version": "0.1.3", + "version": "0.1.4", "private": true, "scripts": { "dev": "next dev --experimental-https", diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index 48e75a9..d712c82 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "0.1.3", tahap: "beta", update: "-perbaikan fitur diskusi dan perbaikan tampilan mobile" }, { status: 200 }); + return NextResponse.json({ success: true, version: "0.1.4", 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 }); From 3f41155d406c4b00e62632fc3f01a28edb61fa39 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 13:58:05 +0800 Subject: [PATCH 34/46] refactor: version-app read from package.json --- src/app/api/version-app/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index d712c82..6813b1e 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; +import { version } from "../../../../package.json"; export async function GET(request: Request) { try { - return NextResponse.json({ success: true, version: "0.1.4", tahap: "beta", update: "-perbaikan fitur diskusi dan perbaikan tampilan mobile" }, { 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 }); From 3e9fbacd949d04d3667cba3e0d614e820c481a33 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 13:58:11 +0800 Subject: [PATCH 35/46] bump: version 0.1.5 --- package.json | 2 +- src/app/api/version-app/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 147e499..fe46cf9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sistem-desa-mandiri", - "version": "0.1.4", + "version": "0.1.5", "private": true, "scripts": { "dev": "next dev --experimental-https", diff --git a/src/app/api/version-app/route.ts b/src/app/api/version-app/route.ts index 6813b1e..f14d46d 100644 --- a/src/app/api/version-app/route.ts +++ b/src/app/api/version-app/route.ts @@ -6,6 +6,6 @@ export async function GET(request: Request) { 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 }); } } From 4f870a5c165b458bc1ff641c0434c76c681da7dd Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 14:28:26 +0800 Subject: [PATCH 36/46] fix: treat 524/504 timeout as accepted on repull --- .github/workflows/script/re-pull.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/script/re-pull.sh b/.github/workflows/script/re-pull.sh index 8097813..7c19ae3 100644 --- a/.github/workflows/script/re-pull.sh +++ b/.github/workflows/script/re-pull.sh @@ -48,9 +48,11 @@ HTTP_STATUS=$(curl -s -o /tmp/portainer_response.json -w "%{http_code}" \ -H "Content-Type: application/json" \ -d "$PAYLOAD") -if [ "$HTTP_STATUS" != "200" ]; then +if [ "$HTTP_STATUS" = "524" ] || [ "$HTTP_STATUS" = "504" ] || [ "$HTTP_STATUS" = "408" ]; then + echo "⚠️ HTTP $HTTP_STATUS (gateway timeout) — Portainer tetap memproses redeploy, lanjut polling container..." +elif [ "$HTTP_STATUS" != "200" ]; then echo "❌ Redeploy gagal! HTTP Status: $HTTP_STATUS" - cat /tmp/portainer_response.json | jq . + cat /tmp/portainer_response.json | jq . 2>/dev/null || true exit 1 fi From d5a38eb0f5829b3ee8cb999a534c9e39c2bd7a72 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 14:30:13 +0800 Subject: [PATCH 37/46] =?UTF-8?q?fix:=20anti-zombie=20polling=20=E2=80=94?= =?UTF-8?q?=20curl=20timeout=20+=20adaptive=20MAX=5FRETRY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/script/re-pull.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/script/re-pull.sh b/.github/workflows/script/re-pull.sh index 7c19ae3..1528e1d 100644 --- a/.github/workflows/script/re-pull.sh +++ b/.github/workflows/script/re-pull.sh @@ -50,22 +50,24 @@ HTTP_STATUS=$(curl -s -o /tmp/portainer_response.json -w "%{http_code}" \ 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..." +echo "⏳ Menunggu container running (max $((MAX_RETRY * 10))s)..." -MAX_RETRY=15 COUNT=0 while [ $COUNT -lt $MAX_RETRY ]; do - sleep 5 + sleep 10 COUNT=$((COUNT + 1)) - CONTAINERS=$(curl -s -X GET \ + 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}") @@ -91,5 +93,5 @@ while [ $COUNT -lt $MAX_RETRY ]; do done echo "" -echo "❌ Timeout! Stack tidak kunjung running setelah $((MAX_RETRY * 5)) detik." +echo "❌ Timeout! Stack tidak kunjung running setelah $((MAX_RETRY * 10)) detik." exit 1 \ No newline at end of file From a58441c4d6e8cf13424fca8766880b124e04fc4c Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 16:32:04 +0800 Subject: [PATCH 38/46] feat: run prisma migrate deploy on container startup --- Dockerfile | 4 +++- entrypoint.sh | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 9023548..faf828b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,11 +67,12 @@ 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 +COPY entrypoint.sh ./entrypoint.sh # 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 +RUN chown -R nextjs:nodejs /app && chmod +x entrypoint.sh USER nextjs @@ -80,4 +81,5 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +ENTRYPOINT ["./entrypoint.sh"] CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..9a9a2b3 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "⏳ Menjalankan migrasi database..." +./node_modules/.bin/prisma migrate deploy +echo "✅ Migrasi selesai." + +exec "$@" From f9b2eb0a8089a82451e14a567f24860be3bc8f79 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 16:33:49 +0800 Subject: [PATCH 39/46] revert: remove entrypoint migration --- Dockerfile | 4 +--- entrypoint.sh | 8 -------- 2 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index faf828b..9023548 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,12 +67,11 @@ 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 -COPY entrypoint.sh ./entrypoint.sh # 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 && chmod +x entrypoint.sh +RUN chown -R nextjs:nodejs /app USER nextjs @@ -81,5 +80,4 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -ENTRYPOINT ["./entrypoint.sh"] CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index 9a9a2b3..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -set -e - -echo "⏳ Menjalankan migrasi database..." -./node_modules/.bin/prisma migrate deploy -echo "✅ Migrasi selesai." - -exec "$@" From c5c288328115703e56568a6b9c48a33d276f9733 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 16:42:51 +0800 Subject: [PATCH 40/46] bump: version 0.1.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fe46cf9..9f15aea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sistem-desa-mandiri", - "version": "0.1.5", + "version": "0.1.6", "private": true, "scripts": { "dev": "next dev --experimental-https", From 81de073222de58d6d49009af419de39b9c4e320d Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 23 Apr 2026 17:26:16 +0800 Subject: [PATCH 41/46] feat: add deploy-stg MCP server --- .gitignore | 1 + .mcp.json | 13 ++ .mcp/deploy-stg/bun.lock | 194 +++++++++++++++++ .mcp/deploy-stg/package.json | 9 + .mcp/deploy-stg/server.ts | 393 +++++++++++++++++++++++++++++++++++ 5 files changed, 610 insertions(+) create mode 100644 .mcp.json create mode 100644 .mcp/deploy-stg/bun.lock create mode 100644 .mcp/deploy-stg/package.json create mode 100644 .mcp/deploy-stg/server.ts 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..c3614e7 --- /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}:main`, "--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); From a53568da8fd897821f7f7867c2824b3fbf784fa5 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Fri, 24 Apr 2026 15:49:57 +0800 Subject: [PATCH 42/46] docs: split CLAUDE.md into focused reference files Move architecture, env vars, and deployment details into .claude/ subdocs referenced via @-imports, keeping CLAUDE.md to commands and pointers only. Co-Authored-By: Claude Sonnet 4.6 --- .claude/ARCHITECTURE.md | 43 +++++++++++++++++++++++++++++++ .claude/DEPLOYMENT.md | 5 ++++ .claude/ENV.md | 12 +++++++++ CLAUDE.md | 56 +++-------------------------------------- 4 files changed, 63 insertions(+), 53 deletions(-) create mode 100644 .claude/ARCHITECTURE.md create mode 100644 .claude/DEPLOYMENT.md create mode 100644 .claude/ENV.md 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/CLAUDE.md b/CLAUDE.md index 378917c..e0a9a91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,62 +20,12 @@ npx prisma generate # Regenerate Prisma client after schema changes ## Architecture -**Sistem Desa Mandiri** is a village administration platform built on Next.js 14 (App Router) with PostgreSQL. - -### Key Layers - -- **`src/app/(application)/`** — Auth-protected pages grouped by feature (announcement, division, project, discussion, member, profile, home, group) -- **`src/app/(auth)/`** — Login/register pages -- **`src/app/api/`** — REST API endpoints; subdirectories map to resource types (`/api/announcement`, `/api/project`, `/api/task`, etc.). Mobile-specific endpoints live under `/api/mobile/` -- **`src/module/`** — Business logic modules, one per feature (19 modules). Each module contains hooks, components, and API call functions for that domain -- **`src/lib/`** — Shared utilities: Prisma client singleton (`prisma.ts`), Firebase init, route definitions (`routes.ts`), push notification hooks - -### Data Access Pattern - -All DB access goes through the Prisma client singleton in `src/lib/prisma.ts`. Prisma schema is at `prisma/schema.prisma` (40+ models). Migrations live in `prisma/migrations/`. - -### State Management - -- **Hookstate** (`@hookstate/core` + `@hookstate/localstored`) for client-side global state with localStorage persistence -- **Iron-session** for server-side session management / auth -- **Jose** for JWT handling - -### UI Stack - -- **Mantine 7** is the primary UI library (components, forms, modals, notifications, charts, dates, etc.) -- **Tailwind CSS** for utility classes — used alongside Mantine -- **PostCSS** configured with Mantine preset (`postcss.config.mjs`) - -### Real-time & Notifications - -- **Firebase FCM** (`src/lib/firebase/`) for mobile push notifications -- **Web Push + VAPID keys** (`src/lib/usePushNotifications.ts`) for browser push -- **wibu-realtime** (custom library) for WebSocket-based real-time updates - -### User Roles - -Five roles with distinct access levels (see `PANDUAN PENGGUNAAN.md`): -1. **Super Admin** — full system access -2. **Admin Desa** — village-level administration -3. **Ketua Divisi** — division leader -4. **Anggota Divisi** — division member -5. **Warga/Perangkat Desa** — village resident/official +See @.claude/ARCHITECTURE.md ## Environment Variables -Copy `.env.example` to `.env`. Required variables: - -| Variable | Purpose | -|---|---| -| `DATABASE_URL` | PostgreSQL connection string | -| `GOOGLE_PROJECT_ID`, `GOOGLE_CLIENT_EMAIL`, `GOOGLE_PRIVATE_KEY` | Firebase Admin SDK (FCM) | -| `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | Web Push | -| `WS_APIKEY` | WebSocket/file storage API key | -| `WIBU_REALTIME_KEY` | Real-time communication | -| `FCM_KEY` | Firebase Cloud Messaging | +See @.claude/ENV.md ## Deployment -Docker images are built via `.github/workflows/publish.yml` and pushed to GHCR (`ghcr.io`). Portainer redeploys via `.github/workflows/re-pull.yml`. Supports `dev`, `stg`, and `prod` stacks. - -The Dockerfile uses a two-stage build: Bun builder → Bun runner (non-root user, port 3000). +See @.claude/DEPLOYMENT.md From 242d8fa2195ee4d7166205131330d6bb604a00c6 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 30 Apr 2026 11:38:24 +0800 Subject: [PATCH 43/46] fix: allow null for idPosition on edit-user endpoint --- src/app/api/monitoring/[[...slug]]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 84c62af..96e4cb4 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -1503,7 +1503,7 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) idUserRole: t.String({ description: "ID Role" }), idVillage: t.String({ description: "ID Desa" }), idGroup: t.String({ description: "ID Group" }), - idPosition: t.Optional(t.String({ description: "ID Posisi" })), + idPosition: t.Optional(t.Union([t.String(), t.Null()], { description: "ID Posisi" })), isActive: t.Boolean({ description: "Aktif" }), isWithoutOTP: t.Boolean({ description: "Tanpa OTP" }), }), From 191e3624b87d967089929970256d264d8cc9855a Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 30 Apr 2026 13:48:12 +0800 Subject: [PATCH 44/46] feat: add API key protection for /api/monitoring endpoints --- .env.example | 6 ++++++ src/app/api/monitoring/[[...slug]]/route.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.env.example b/.env.example index bdd6950..0526ea8 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,12 @@ VAPID_PRIVATE_KEY="UHDY8M3-0beVIA2kt2zL3ZeMStJ0j6zVkVd2Cfqpgrc" # API key for file operations (upload, delete, copy, view directory) WS_APIKEY="your-websocket-api-key" +# =========================================== +# MONITORING API +# =========================================== +# API key untuk akses endpoint /api/monitoring (header: x-api-key) +MONITORING_API_KEY="your-monitoring-api-key" + # =========================================== # APPLICATION SETTINGS # =========================================== diff --git a/src/app/api/monitoring/[[...slug]]/route.ts b/src/app/api/monitoring/[[...slug]]/route.ts index 96e4cb4..f3ebf0b 100644 --- a/src/app/api/monitoring/[[...slug]]/route.ts +++ b/src/app/api/monitoring/[[...slug]]/route.ts @@ -25,6 +25,18 @@ const MonitoringServer = new Elysia({ prefix: "/api/monitoring" }) } } })) + .onBeforeHandle(({ request, set, path }) => { + // Docs tidak perlu API key + if (path.startsWith("/api/monitoring/docs")) return; + + const apiKey = process.env.MONITORING_API_KEY; + const incoming = request.headers.get("x-api-key"); + + if (!apiKey || incoming !== apiKey) { + set.status = 401; + return { success: false, message: "Unauthorized" }; + } + }) .get("/grid-overview", async ({ query, set }) => { try { From 705992df452af2685584126282f38a05aa2519af Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 30 Apr 2026 15:01:28 +0800 Subject: [PATCH 45/46] fix: push to stg branch on build remote instead of main --- .mcp/deploy-stg/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mcp/deploy-stg/server.ts b/.mcp/deploy-stg/server.ts index c3614e7..c84ea4f 100644 --- a/.mcp/deploy-stg/server.ts +++ b/.mcp/deploy-stg/server.ts @@ -197,7 +197,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // 3. Push to build remote (GitHub) const currentBranch = GIT(["rev-parse", "--abbrev-ref", "HEAD"]); - GIT(["push", "build", `${currentBranch}:main`, "--force"]); + GIT(["push", "build", `${currentBranch}:stg`, "--force"]); // 4. Trigger publish GH([ From fa16c05cde044064c4a3f4831860068915518b13 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Thu, 30 Apr 2026 15:01:47 +0800 Subject: [PATCH 46/46] bump: version 0.1.7 + migration --- package.json | 2 +- prisma/migrations/20260430070147_auto/migration.sql | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260430070147_auto/migration.sql diff --git a/package.json b/package.json index 9f15aea..c7553a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sistem-desa-mandiri", - "version": "0.1.6", + "version": "0.1.7", "private": true, "scripts": { "dev": "next dev --experimental-https", 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