Compare commits
91 Commits
nico/9-mar
...
tasks/umkm
| Author | SHA1 | Date | |
|---|---|---|---|
| 58ab306428 | |||
| 97902f6277 | |||
| ef7d1752de | |||
| f9de4b7a35 | |||
| 13873c9fe7 | |||
| 03b084d9d4 | |||
| 5df9698599 | |||
| 3d3e5ffc87 | |||
| e80e333eed | |||
| b1289831f3 | |||
| 3c4e273e26 | |||
| de4563c914 | |||
| 11ff5f5c01 | |||
| ed44222594 | |||
| fd7579d6d3 | |||
| e7c3c020c2 | |||
| 6873e84848 | |||
| 74dc9e5c18 | |||
| 04001c905b | |||
| 62a9a49502 | |||
| 80186bf493 | |||
| e669dcee25 | |||
| d84edc44f5 | |||
| 8b14c6ce44 | |||
| 5e822f0b05 | |||
| 34a37dc63b | |||
| 0e6f7e1769 | |||
| feb853d06e | |||
| 3de412afe0 | |||
| 87d234e57f | |||
| fd18a22834 | |||
| 3e8b961e52 | |||
| 82d779e5e0 | |||
| a6517166cb | |||
| 483b6be677 | |||
| f8dad0dbcd | |||
| 74301fe074 | |||
| 8b19abc628 | |||
| e73798a0f2 | |||
| 1a91f3c9ad | |||
| 9b74592101 | |||
| 55f4b94082 | |||
| b403bc754c | |||
| 67b87f145e | |||
| dd09d7c90a | |||
| 59ae8ad039 | |||
| c012d5778c | |||
| af31bd8aef | |||
| 721357adcf | |||
| 39776ec355 | |||
| 50a7356618 | |||
| 4494dd98ef | |||
| 970949a68b | |||
| 8777c45a44 | |||
| 42bcba6c96 | |||
| d1d54e5c25 | |||
| 0a4b85fd82 | |||
| b751f031cd | |||
| a3940321a7 | |||
| 3cd6fcbd81 | |||
| 7d9b7b0c60 | |||
| 0806eb2308 | |||
|
|
6064ef0759 | ||
| 1c00c326c9 | |||
| 51ce823b45 | |||
| 4eba96140d | |||
| f6f0e10935 | |||
| 2108f403aa | |||
| 4dfcf20322 | |||
| c6c3eebadf | |||
|
|
6d26ace8ab | ||
|
|
0dabc204bc | ||
|
|
e8f8b51686 | ||
|
|
a4db3a149d | ||
|
|
fece983ac5 | ||
| 8b7eef5fee | |||
| 8b22d01e0d | |||
| dc13e37a02 | |||
| 2d2cbef29b | |||
| 8c8a96b830 | |||
| dc3eccacbf | |||
| ffe94992e5 | |||
|
|
f5566bca2c | ||
|
|
ba964df32c | ||
|
|
df3f382a97 | ||
|
|
7368a367f4 | ||
|
|
ed664d5b10 | ||
|
|
0ba30aa5b2 | ||
|
|
790d6535e5 | ||
|
|
46ce16ae97 | ||
| d66a952d4c |
47
.dockerignore
Normal file
47
.dockerignore
Normal file
@@ -0,0 +1,47 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
bun-debug.log*
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Markdown/Documentation
|
||||
README.md
|
||||
GEMINI.md
|
||||
AGENTS.md
|
||||
AUDIT_REPORT.md
|
||||
QWEN.md
|
||||
NOTE.md
|
||||
task-project-apbdes.md
|
||||
MUSIK_CREATE_ANALYSIS.md
|
||||
darkMode.md
|
||||
/test-results
|
||||
/playwright-report
|
||||
/tmp_assets
|
||||
/foldergambar
|
||||
/googleapi
|
||||
/xx
|
||||
/xx.ts
|
||||
/xx.txt
|
||||
/test.txt
|
||||
/x.json
|
||||
/x.sh
|
||||
/xcoba.ts
|
||||
/xcoba2.ts
|
||||
/gambar.ttx
|
||||
/test-berita-state.ts
|
||||
44
.env.example
Normal file
44
.env.example
Normal file
@@ -0,0 +1,44 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/desa-darmasaba?schema=public"
|
||||
|
||||
# Seafile Configuration (File Storage)
|
||||
SEAFILE_TOKEN=your_seafile_token
|
||||
SEAFILE_REPO_ID=your_seafile_repo_id
|
||||
SEAFILE_URL=https://your-seafile-instance.com
|
||||
SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
|
||||
|
||||
# Upload Configuration
|
||||
WIBU_UPLOAD_DIR=uploads
|
||||
WIBU_DOWNLOAD_DIR=./download
|
||||
|
||||
# WhatsApp Server Configuration
|
||||
WA_SERVER_TOKEN=your_whatsapp_server_token
|
||||
|
||||
# Application Configuration
|
||||
# IMPORTANT: For staging/production, set this to your actual domain
|
||||
# Local development: NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
# Staging: NEXT_PUBLIC_BASE_URL=https://desa-darmasaba-stg.wibudev.com
|
||||
# Production: NEXT_PUBLIC_BASE_URL=https://your-production-domain.com
|
||||
# Or use relative URL '/' for automatic protocol/domain detection (recommended)
|
||||
NEXT_PUBLIC_BASE_URL=/
|
||||
|
||||
# Email Configuration (for notifications/subscriptions)
|
||||
EMAIL_USER=your_email@gmail.com
|
||||
EMAIL_PASS=your_email_app_password
|
||||
|
||||
# Session Configuration
|
||||
BASE_SESSION_KEY=your_session_key_generate_secure_random_string
|
||||
BASE_TOKEN_KEY=your_jwt_secret_key_generate_secure_random_string
|
||||
|
||||
# Telegram Bot Configuration (for notifications)
|
||||
BOT_TOKEN=your_telegram_bot_token
|
||||
CHAT_ID=your_telegram_chat_id
|
||||
|
||||
# Session Password (for iron-session)
|
||||
SESSION_PASSWORD="your_session_password_min_32_characters_long_secure"
|
||||
|
||||
# ElevenLabs API Key (for TTS features - optional)
|
||||
ELEVENLABS_API_KEY=your_elevenlabs_api_key
|
||||
|
||||
# Environment (optional, defaults to development)
|
||||
# NODE_ENV=development
|
||||
@@ -1,43 +1,52 @@
|
||||
#!/usr/bin/env bun
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Fungsi untuk mencari string terpanjang dalam objek (biasanya balasan AI)
|
||||
function findLongestString(obj: any): string {
|
||||
let longest = "";
|
||||
const search = (item: any) => {
|
||||
if (typeof item === "string") {
|
||||
if (item.length > longest.length) longest = item;
|
||||
} else if (Array.isArray(item)) {
|
||||
item.forEach(search);
|
||||
} else if (item && typeof item === "object") {
|
||||
Object.values(item).forEach(search);
|
||||
// Function to manually load .env from project root if process.env is missing keys
|
||||
function loadEnv() {
|
||||
const envPath = join(process.cwd(), ".env");
|
||||
if (existsSync(envPath)) {
|
||||
const envContent = readFileSync(envPath, "utf-8");
|
||||
const lines = envContent.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line && !line.startsWith("#")) {
|
||||
const [key, ...valueParts] = line.split("=");
|
||||
if (key && valueParts.length > 0) {
|
||||
const value = valueParts.join("=").trim().replace(/^["']|["']$/g, "");
|
||||
process.env[key.trim()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
search(obj);
|
||||
return longest;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// Ensure environment variables are loaded
|
||||
loadEnv();
|
||||
|
||||
const inputRaw = readFileSync(0, "utf-8");
|
||||
if (!inputRaw) return;
|
||||
const input = JSON.parse(inputRaw);
|
||||
|
||||
// DEBUG: Lihat struktur asli di console terminal (stderr)
|
||||
console.error("DEBUG KEYS:", Object.keys(input));
|
||||
let finalText = "";
|
||||
let sessionId = "web-desa-darmasaba";
|
||||
|
||||
try {
|
||||
// Try parsing as JSON first
|
||||
const input = JSON.parse(inputRaw);
|
||||
sessionId = input.session_id || "web-desa-darmasaba";
|
||||
finalText = typeof input === "string" ? input : (input.response || input.text || JSON.stringify(input));
|
||||
} catch {
|
||||
// If not JSON, use raw text
|
||||
finalText = inputRaw;
|
||||
}
|
||||
|
||||
const BOT_TOKEN = process.env.BOT_TOKEN;
|
||||
const CHAT_ID = process.env.CHAT_ID;
|
||||
|
||||
const sessionId = input.session_id || "unknown";
|
||||
|
||||
// Cari teks secara otomatis di seluruh objek JSON
|
||||
let finalText = findLongestString(input.response || input);
|
||||
|
||||
if (!finalText || finalText.length < 5) {
|
||||
finalText =
|
||||
"Teks masih gagal diekstraksi. Struktur: " +
|
||||
Object.keys(input).join(", ");
|
||||
if (!BOT_TOKEN || !CHAT_ID) {
|
||||
console.error("Missing BOT_TOKEN or CHAT_ID in environment variables");
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
@@ -45,7 +54,7 @@ async function run() {
|
||||
`🆔 Session: \`${sessionId}\` \n\n` +
|
||||
`🧠 Output:\n${finalText.substring(0, 3500)}`;
|
||||
|
||||
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -55,6 +64,13 @@ async function run() {
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
console.error("Telegram API Error:", errorData);
|
||||
} else {
|
||||
console.log("Notification sent successfully!");
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ status: "continue" }));
|
||||
} catch (err) {
|
||||
console.error("Hook Error:", err);
|
||||
|
||||
219
.github/workflows/build.yml
vendored
219
.github/workflows/build.yml
vendored
@@ -1,219 +0,0 @@
|
||||
name: Build And Save Log
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Target environment (e.g., staging, production)"
|
||||
required: true
|
||||
default: "staging"
|
||||
version:
|
||||
description: "Version to deploy"
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
env:
|
||||
APP_NAME: desa-darmasaba-action
|
||||
WA_PHONE: "6289697338821,6289697338822"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
|
||||
steps:
|
||||
# Checkout kode sumber
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Setup Bun
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
# Cache dependencies
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .bun
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
# Step 1: Set BRANCH_NAME based on event type
|
||||
- name: Set BRANCH_NAME
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Step 2: Generate APP_VERSION dynamically
|
||||
- name: Set APP_VERSION
|
||||
run: echo "APP_VERSION=${{ github.sha }}---$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV
|
||||
|
||||
# Step 3: Kirim notifikasi ke API build Start
|
||||
- name: Notify start build
|
||||
run: |
|
||||
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
|
||||
for PHONE in "${PHONES[@]}"; do
|
||||
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:start\nApp:${{ env.APP_NAME }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}'))")
|
||||
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
|
||||
done
|
||||
|
||||
# Install dependencies
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
# Konfigurasi environment variable untuk PostgreSQL dan variabel tambahan
|
||||
- name: Set up environment variables
|
||||
run: |
|
||||
echo "DATABASE_URL=postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.POSTGRES_DB }}?schema=public" >> .env
|
||||
echo "PORT=3000" >> .env
|
||||
echo "NEXT_PUBLIC_WIBU_URL=localhost:3000" >> .env
|
||||
echo "WIBU_UPLOAD_DIR=/uploads" >> .env
|
||||
|
||||
# Create log file
|
||||
- name: Create log file
|
||||
run: touch build.txt
|
||||
|
||||
# Migrasi database menggunakan Prisma
|
||||
- name: Apply Prisma schema to database
|
||||
run: bun prisma db push >> build.txt 2>&1
|
||||
|
||||
# Seed database (opsional)
|
||||
- name: Seed database
|
||||
run: |
|
||||
bun prisma db seed >> build.txt 2>&1 || echo "Seed failed or no seed data found. Continuing without seed." >> build.txt
|
||||
|
||||
# Build project
|
||||
- name: Build project
|
||||
run: bun run build >> build.txt 2>&1
|
||||
|
||||
# Ensure project directory exists
|
||||
- name: Ensure /var/www/projects/${{ env.APP_NAME }} exists
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USERNAME }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
script: |
|
||||
mkdir -p /var/www/projects/${{ env.APP_NAME }}
|
||||
|
||||
# Deploy to a new version directory
|
||||
- name: Deploy to VPS (New Version)
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USERNAME }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
source: "."
|
||||
target: "/var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}"
|
||||
|
||||
# Set up environment variables
|
||||
- name: Set up environment variables
|
||||
run: |
|
||||
rm -r .env
|
||||
echo "DATABASE_URL=postgresql://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5433/${{ secrets.POSTGRES_DB }}?schema=public" >> .env
|
||||
echo "NEXT_PUBLIC_WIBU_URL=${{ env.APP_NAME }}" >> .env
|
||||
echo "WIBU_UPLOAD_DIR=/var/www/projects/${{ env.APP_NAME }}/uploads" >> .env
|
||||
|
||||
# Kirim file .env ke server
|
||||
- name: Upload .env to server
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USERNAME }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
source: ".env"
|
||||
target: "/var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}/"
|
||||
|
||||
# manage deployment
|
||||
- name: manage deployment
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USERNAME }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
script: |
|
||||
|
||||
# Source ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# Find an available port
|
||||
PORT=$(curl -s -X GET https://wibu-bot.wibudev.com/api/find-port | jq -r '.[0]')
|
||||
if [ -z "$PORT" ] || ! [[ "$PORT" =~ ^[0-9]+$ ]]; then
|
||||
echo "Invalid or missing port from API."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# manage deployment
|
||||
cd /var/www/projects/${{ env.APP_NAME }}/releases/${{ env.APP_VERSION }}
|
||||
|
||||
# Create uploads directory
|
||||
mkdir -p /var/www/projects/${{ env.APP_NAME }}/uploads
|
||||
|
||||
# Install dependencies
|
||||
bun install --production
|
||||
|
||||
# Apply database schema
|
||||
if ! bun prisma db push; then
|
||||
echo "Database migration failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Seed database (optional)
|
||||
bun prisma db seed || echo "tidak membutuhkan seed"
|
||||
|
||||
# Restart the application
|
||||
pm2 reload ${{ env.APP_NAME }} || pm2 start "bun run start --port $PORT" --name "${{ env.APP_NAME }}-$PORT" --namespace "${{ env.APP_NAME }}"
|
||||
|
||||
# Step 4: Set BUILD_STATUS based on success or failure
|
||||
- name: Set BUILD_STATUS
|
||||
if: success()
|
||||
run: echo "BUILD_STATUS=success" >> $GITHUB_ENV
|
||||
|
||||
- name: Set BUILD_STATUS on failure
|
||||
if: failure()
|
||||
run: echo "BUILD_STATUS=failed" >> $GITHUB_ENV
|
||||
|
||||
# Update status log
|
||||
- name: Update status log
|
||||
if: always()
|
||||
run: |
|
||||
echo "=====================" >> build.txt
|
||||
echo "BUILD_STATUS=${{ env.BUILD_STATUS }}" >> build.txt
|
||||
echo "APP_NAME=${{ env.APP_NAME }}" >> build.txt
|
||||
echo "APP_VERSION=${{ env.APP_VERSION }}" >> build.txt
|
||||
echo "=====================" >> build.txt
|
||||
|
||||
# Upload log to 0x0.st
|
||||
- name: Upload log to 0x0.st
|
||||
id: upload_log
|
||||
if: always()
|
||||
run: |
|
||||
LOG_URL=$(curl -F "file=@build.txt" https://wibu-bot.wibudev.com/api/file )
|
||||
echo "LOG_URL=$LOG_URL" >> $GITHUB_ENV
|
||||
|
||||
# Kirim notifikasi ke API
|
||||
- name: Notify build success via API
|
||||
if: always()
|
||||
run: |
|
||||
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
|
||||
for PHONE in "${PHONES[@]}"; do
|
||||
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:${{ env.BUILD_STATUS }}\nApp:${{ env.APP_NAME }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}\nLog:${{ env.LOG_URL }}'))")
|
||||
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
|
||||
done
|
||||
56
.github/workflows/docker-publish.yml
vendored
Normal file
56
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Publish Docker to GHCR
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
jobs:
|
||||
publish:
|
||||
name: Build & Push to GHCR
|
||||
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: Extract tag name
|
||||
id: meta
|
||||
run: echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
106
.github/workflows/publish.yml
vendored
Normal file
106
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
name: Publish Docker to GHCR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stack_env:
|
||||
description: "stack env"
|
||||
required: true
|
||||
type: choice
|
||||
default: "dev"
|
||||
options:
|
||||
- dev
|
||||
- prod
|
||||
- stg
|
||||
tag:
|
||||
description: "Image tag (e.g. 1.0.0)"
|
||||
required: true
|
||||
default: "1.0.0"
|
||||
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build & Push to GHCR ${{ github.repository }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ vars.PORTAINER_ENV || 'portainer' }}
|
||||
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 branch ${{ github.event.inputs.stack_env }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.stack_env }}
|
||||
|
||||
- name: Checkout scripts from main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
path: .ci
|
||||
sparse-checkout: .github/workflows/script
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}
|
||||
type=raw,value=${{ github.event.inputs.stack_env }}-latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
|
||||
- name: Notify success
|
||||
if: success()
|
||||
run: bash ./.ci/.github/workflows/script/notify.sh
|
||||
env:
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
NOTIFY_STATUS: success
|
||||
NOTIFY_WORKFLOW: "Publish Docker"
|
||||
NOTIFY_DETAIL: "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}"
|
||||
|
||||
- name: Notify failure
|
||||
if: failure()
|
||||
run: bash ./.ci/.github/workflows/script/notify.sh
|
||||
env:
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
NOTIFY_STATUS: failure
|
||||
NOTIFY_WORKFLOW: "Publish Docker"
|
||||
NOTIFY_DETAIL: "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.stack_env }}-${{ github.event.inputs.tag }}"
|
||||
60
.github/workflows/re-pull.yml
vendored
Normal file
60
.github/workflows/re-pull.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
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 scripts from main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
sparse-checkout: .github/workflows/script
|
||||
|
||||
- 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 }}
|
||||
|
||||
- name: Notify success
|
||||
if: success()
|
||||
run: bash ./.github/workflows/script/notify.sh
|
||||
env:
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
NOTIFY_STATUS: success
|
||||
NOTIFY_WORKFLOW: "Re-Pull Docker"
|
||||
NOTIFY_DETAIL: "Stack: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}"
|
||||
|
||||
- name: Notify failure
|
||||
if: failure()
|
||||
run: bash ./.github/workflows/script/notify.sh
|
||||
env:
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
NOTIFY_STATUS: failure
|
||||
NOTIFY_WORKFLOW: "Re-Pull Docker"
|
||||
NOTIFY_DETAIL: "Stack: ${{ github.event.inputs.stack_name }}-${{ github.event.inputs.stack_env }}"
|
||||
26
.github/workflows/script/notify.sh
vendored
Normal file
26
.github/workflows/script/notify.sh
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
: "${TELEGRAM_TOKEN:?TELEGRAM_TOKEN tidak di-set}"
|
||||
: "${TELEGRAM_CHAT_ID:?TELEGRAM_CHAT_ID tidak di-set}"
|
||||
: "${NOTIFY_STATUS:?NOTIFY_STATUS tidak di-set}"
|
||||
: "${NOTIFY_WORKFLOW:?NOTIFY_WORKFLOW tidak di-set}"
|
||||
|
||||
if [ "$NOTIFY_STATUS" = "success" ]; then
|
||||
ICON="✅"
|
||||
TEXT="${ICON} *${NOTIFY_WORKFLOW}* berhasil!"
|
||||
else
|
||||
ICON="❌"
|
||||
TEXT="${ICON} *${NOTIFY_WORKFLOW}* gagal!"
|
||||
fi
|
||||
|
||||
if [ -n "$NOTIFY_DETAIL" ]; then
|
||||
TEXT="${TEXT}
|
||||
${NOTIFY_DETAIL}"
|
||||
fi
|
||||
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg text "$TEXT" \
|
||||
'{chat_id: $chat_id, text: $text, parse_mode: "Markdown"}')"
|
||||
120
.github/workflows/script/re-pull.sh
vendored
Normal file
120
.github/workflows/script/re-pull.sh
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/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}"
|
||||
|
||||
# Timeout total: MAX_RETRY * SLEEP_INTERVAL detik
|
||||
MAX_RETRY=60 # 60 × 10s = 10 menit
|
||||
SLEEP_INTERVAL=10
|
||||
|
||||
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!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STACK_ID=$(echo "$STACK" | jq -r .Id)
|
||||
ENDPOINT_ID=$(echo "$STACK" | jq -r .EndpointId)
|
||||
ENV=$(echo "$STACK" | jq '.Env // []')
|
||||
|
||||
# ── Catat container ID lama sebelum redeploy ──────────────────────────────────
|
||||
echo "📸 Mencatat container aktif sebelum redeploy..."
|
||||
CONTAINERS_BEFORE=$(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}")
|
||||
|
||||
OLD_IDS=$(echo "$CONTAINERS_BEFORE" | jq -r '[.[] | .Id] | join(",")')
|
||||
echo " Container lama: $(echo "$CONTAINERS_BEFORE" | jq -r '[.[] | .Names[0]] | join(", ")')"
|
||||
|
||||
# ── Ambil compose file lalu trigger redeploy ─────────────────────────────────
|
||||
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 "🚀 Triggering redeploy $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 image selesai di-pull dan container baru running..."
|
||||
echo " (Timeout: $((MAX_RETRY * SLEEP_INTERVAL)) detik)"
|
||||
|
||||
COUNT=0
|
||||
while [ $COUNT -lt $MAX_RETRY ]; do
|
||||
sleep $SLEEP_INTERVAL
|
||||
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}")
|
||||
|
||||
# Container baru = ID tidak ada di daftar container lama
|
||||
NEW_RUNNING=$(echo "$CONTAINERS" | jq \
|
||||
--arg old "$OLD_IDS" \
|
||||
'[.[] | select(.State == "running" and ((.Id) as $id | ($old | split(",") | index($id)) == null))] | length')
|
||||
|
||||
FAILED=$(echo "$CONTAINERS" | jq \
|
||||
'[.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not) and (.Names[0] | test("seed") | not))] | length')
|
||||
|
||||
echo "🔄 [$((COUNT * SLEEP_INTERVAL))s / $((MAX_RETRY * SLEEP_INTERVAL))s] Container baru running: ${NEW_RUNNING} | Gagal: ${FAILED}"
|
||||
echo "$CONTAINERS" | jq -r '.[] | " → \(.Names[0]) | \(.State) | \(.Status) | id: \(.Id[:12])"'
|
||||
|
||||
if [ "$FAILED" -gt "0" ]; then
|
||||
echo ""
|
||||
echo "❌ Ada container yang crash!"
|
||||
echo "$CONTAINERS" | jq -r '.[] | select(.State == "exited" and (.Status | test("Exited \\(0\\)") | not) and (.Names[0] | test("seed") | not)) | " → \(.Names[0]) | \(.Status)"'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$NEW_RUNNING" -gt "0" ]; then
|
||||
# Cleanup dangling images setelah redeploy sukses
|
||||
echo "🧹 Membersihkan dangling images..."
|
||||
curl -s -X POST "https://${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/images/prune" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"filters":{"dangling":["true"]}}' | jq -r '" Reclaimed: \(.SpaceReclaimed // 0 | . / 1073741824 | tostring | .[0:5]) GB"'
|
||||
|
||||
echo "✅ Cleanup selesai!"
|
||||
echo ""
|
||||
echo "✅ Stack $STACK_NAME berhasil di-redeploy dengan image baru dan running!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "❌ Timeout $((MAX_RETRY * SLEEP_INTERVAL))s! Container baru tidak kunjung running."
|
||||
echo " Kemungkinan image masih dalam proses pull atau ada error di server."
|
||||
exit 1
|
||||
55
.github/workflows/test.yml
vendored
55
.github/workflows/test.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: test workflows
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Target environment (e.g., staging, production)"
|
||||
required: true
|
||||
default: "staging"
|
||||
version:
|
||||
description: "Version to deploy"
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
env:
|
||||
APP_NAME: desa-darmasaba-action
|
||||
WA_PHONE: "6289697338821,6289697338822"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Checkout kode sumber
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Setup Bun
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
# Create log file
|
||||
- name: Create log file
|
||||
run: touch build.txt
|
||||
|
||||
# Step 1: Set BRANCH_NAME based on event type
|
||||
- name: Set BRANCH_NAME
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Step 2: Generate APP_VERSION dynamically
|
||||
- name: Set APP_VERSION
|
||||
run: echo "APP_VERSION=${{ github.sha }}---$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV
|
||||
|
||||
# Step 3: Kirim notifikasi ke API build Start
|
||||
- name: Notify start build
|
||||
run: |
|
||||
IFS=',' read -ra PHONES <<< "${{ env.WA_PHONE }}"
|
||||
for PHONE in "${PHONES[@]}"; do
|
||||
ENCODED_TEXT=$(bun -e "console.log(encodeURIComponent('Build:start\nApp:${{ env.APP_NAME }}\nenv:${{ inputs.environment }}\nBranch:${{ env.BRANCH_NAME }}\nVersion:${{ env.APP_VERSION }}'))")
|
||||
curl -X GET "https://wa.wibudev.com/code?text=$ENCODED_TEXT&nom=$PHONE"
|
||||
done
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -29,7 +29,9 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env
|
||||
# env local files (keep .env.example)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# QC
|
||||
QC
|
||||
@@ -50,9 +52,9 @@ next-env.d.ts
|
||||
# cache
|
||||
/cache
|
||||
|
||||
.github/
|
||||
|
||||
.env.*
|
||||
|
||||
*.tar.gz
|
||||
|
||||
# local scripts
|
||||
ai.sh
|
||||
|
||||
|
||||
13
.qwen/settings.json
Normal file
13
.qwen/settings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"playwright-mcp@latest"
|
||||
],
|
||||
"timeout": 60000
|
||||
}
|
||||
},
|
||||
"$version": 3
|
||||
}
|
||||
9
.qwen/settings.json.orig
Normal file
9
.qwen/settings.json.orig
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "playwright-mcp@latest"],
|
||||
"timeout": 60000
|
||||
}
|
||||
}
|
||||
}
|
||||
42
AGENTS.md
42
AGENTS.md
@@ -10,7 +10,7 @@ Desa Darmasaba is a Next.js 15 application for village management services in Ba
|
||||
- **Styling**: Mantine UI components with custom CSS
|
||||
- **Backend**: Elysia.js API server integrated with Next.js
|
||||
- **Database**: PostgreSQL with Prisma ORM
|
||||
- **State Management**: Valtio for global state
|
||||
- **State Management**: Jotai for global state
|
||||
- **Authentication**: JWT with iron-session
|
||||
|
||||
## Build Commands
|
||||
@@ -105,39 +105,11 @@ import { MyComponent } from '@/components/my-component'
|
||||
- Add loading states and error states for async operations
|
||||
|
||||
### State Management
|
||||
- Use Valtio for global state (proxy pattern)
|
||||
- State dibagi menjadi admin dan public domains
|
||||
- Use Jotai atoms for global state
|
||||
- Keep local state in components when possible
|
||||
- Use SWR for server state caching
|
||||
- Use React Query (SWR) for server state caching
|
||||
- Implement optimistic updates for better UX
|
||||
|
||||
**State Structure:**
|
||||
```
|
||||
src/state/
|
||||
├── admin/ # Admin dashboard state
|
||||
│ ├── adminNavState.ts
|
||||
│ ├── adminAuthState.ts
|
||||
│ ├── adminFormState.ts
|
||||
│ └── adminModuleState.ts
|
||||
├── public/ # Public pages state
|
||||
│ ├── publicNavState.ts
|
||||
│ └── publicMusicState.ts
|
||||
├── darkModeStore.ts # Dark mode state
|
||||
└── index.ts # Central exports
|
||||
```
|
||||
|
||||
**Usage Examples:**
|
||||
```typescript
|
||||
// Import state
|
||||
import { adminNavState, useAdminNav } from '@/state';
|
||||
|
||||
// In non-React code
|
||||
adminNavState.mobileOpen = true;
|
||||
|
||||
// In React components
|
||||
const { mobileOpen, toggleMobile } = useAdminNav();
|
||||
```
|
||||
|
||||
### Styling
|
||||
- Primary: Mantine UI components
|
||||
- Use Mantine theme system for customization
|
||||
@@ -155,13 +127,9 @@ const { mobileOpen, toggleMobile } = useAdminNav();
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js app router pages
|
||||
├── components/ # Reusable React components
|
||||
├── components/ # Reusable React components
|
||||
├── lib/ # Utility functions and configurations
|
||||
├── state/ # Valtio state management
|
||||
│ ├── admin/ # Admin domain state
|
||||
│ ├── public/ # Public domain state
|
||||
│ └── index.ts # Central exports
|
||||
├── store/ # Legacy store (deprecated)
|
||||
├── state/ # Jotai atoms and state management
|
||||
├── types/ # TypeScript type definitions
|
||||
└── con/ # Constants and static data
|
||||
```
|
||||
|
||||
116
CLAUDE.md
Normal file
116
CLAUDE.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Desa Darmasaba is a full-stack digital village management platform for a village in Badung, Bali. It serves both a public-facing website (`/darmasaba/*`) and an admin CMS (`/admin/*`).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun run dev # Start dev server (port 3000)
|
||||
bun run build # Production build
|
||||
bun run tsc --noEmit # Type-check only
|
||||
|
||||
# Testing
|
||||
bun run test # All tests
|
||||
bun run test:api # Unit tests (Vitest)
|
||||
bun run test:e2e # E2E tests (Playwright)
|
||||
|
||||
# Database
|
||||
bunx prisma migrate deploy # Apply migrations
|
||||
bunx prisma migrate dev --name <name> # Create migration
|
||||
bun run prisma/seed.ts # Seed database
|
||||
bunx prisma studio # Interactive DB viewer
|
||||
|
||||
# Linting
|
||||
bun eslint . --fix
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Tech Stack
|
||||
- **Framework**: Next.js 15 (App Router) + React 19
|
||||
- **Runtime/Package manager**: Bun (not npm)
|
||||
- **API server**: Elysia.js (mounted at `/api/[[...slugs]]`)
|
||||
- **ORM**: Prisma + PostgreSQL
|
||||
- **UI**: Mantine UI v7-8
|
||||
- **State**: Jotai (atoms), Valtio (proxies), SWR (data fetching)
|
||||
- **Auth**: iron-session + JWT
|
||||
- **File storage**: Local uploads + Seafile (self-hosted)
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
Browser → Next.js middleware (src/middleware.ts)
|
||||
→ Public pages: src/app/darmasaba/
|
||||
→ Admin pages: src/app/admin/
|
||||
→ API: src/app/api/[[...slugs]]/route.ts (Elysia.js)
|
||||
└── _lib/*.ts (domain modules)
|
||||
```
|
||||
|
||||
The Elysia server is a single entry point with domain-specific modules: `desa.ts`, `kesehatan.ts`, `ekonomi.ts`, `keamanan.ts`, `lingkungan.ts`, `pendidikan.ts`, `kependudukan.ts`, `ppid.ts`, `inovasi.ts`, `auth/`, `user/`, `fileStorage/`. Swagger docs are auto-generated at `/api/docs`.
|
||||
|
||||
### Domain Modules
|
||||
Each domain (desa, kesehatan, ekonomi, etc.) has:
|
||||
- API handler in `src/app/api/[[...slugs]]/_lib/<domain>.ts`
|
||||
- Admin CMS pages in `src/app/admin/(dashboard)/<domain>/`
|
||||
- Public pages in `src/app/darmasaba/(pages)/<domain>/`
|
||||
|
||||
### Database (Prisma)
|
||||
- Schema at `prisma/schema.prisma` (~2400 lines, 100+ models)
|
||||
- Common model conventions: `@default(cuid())` IDs, `createdAt`/`updatedAt` timestamps, `deletedAt DateTime?` (soft delete), `isActive Boolean @default(true)`
|
||||
- Seeders per-module in `prisma/_seeder_list/`, orchestrated by `prisma/seed.ts`
|
||||
|
||||
### Authentication Flow
|
||||
1. User submits phone → OTP sent (email/SMS)
|
||||
2. OTP validated → JWT created + iron-session stored
|
||||
3. `UserSession` model tracks active sessions
|
||||
4. `src/middleware.ts` validates on each request
|
||||
5. `src/lib/api-auth.ts` handles JWT/session checks in API routes
|
||||
|
||||
### File Handling
|
||||
All uploaded files reference the `FileStorage` Prisma model. Uploads land in `WIBU_UPLOAD_DIR` (default: `uploads/`). Seafile is the external storage fallback.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/middleware.ts` | Route guards and auth |
|
||||
| `src/lib/prisma.ts` | Prisma client singleton |
|
||||
| `src/lib/api-auth.ts` | JWT/session validation |
|
||||
| `src/lib/api-fetch.ts` | Typed fetch wrapper used by frontend |
|
||||
| `src/lib/session.ts` | iron-session config |
|
||||
| `next.config.ts` | Next.js config (cache headers, allowed origins) |
|
||||
| `postcss.config.cjs` | Mantine CSS preset and breakpoints |
|
||||
| `docker-entrypoint.sh` | Runs `prisma migrate deploy` then starts app |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env`. Required variables:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://..."
|
||||
NEXT_PUBLIC_BASE_URL="/"
|
||||
BASE_SESSION_KEY="..." # random string
|
||||
BASE_TOKEN_KEY="..." # random string
|
||||
SESSION_PASSWORD="..." # min 32 chars
|
||||
SEAFILE_TOKEN="..."
|
||||
SEAFILE_REPO_ID="..."
|
||||
SEAFILE_URL="..."
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Multi-stage build: `oven/bun:1-debian` → builder → runner. The runner creates a `nextjs` user (UID 1001), exposes port 3000, and mounts `/app/uploads` as a volume. Entrypoint runs migrations automatically.
|
||||
|
||||
## CI/CD
|
||||
|
||||
GitHub Actions workflows in `.github/workflows/`:
|
||||
- `docker-publish.yml` — triggers on `v*` tags, pushes to GHCR
|
||||
- `publish.yml` — manual build & push
|
||||
- `re-pull.yml` — triggers Portainer to redeploy latest image
|
||||
|
||||
To release: tag with `git tag -a v0.1.x -m "..."` and push the tag.
|
||||
@@ -1,255 +0,0 @@
|
||||
# 🐛 DEBUGGING GUIDE - Music State
|
||||
|
||||
## Problem: `window.publicMusicState` is undefined
|
||||
|
||||
### Possible Causes & Solutions
|
||||
|
||||
---
|
||||
|
||||
### 1️⃣ **Debug Utility Not Loaded**
|
||||
|
||||
**Check:** Open browser console and look for:
|
||||
```
|
||||
[Debug] State exposed to window object:
|
||||
✅ window.publicMusicState
|
||||
✅ window.adminNavState
|
||||
✅ window.adminAuthState
|
||||
```
|
||||
|
||||
**If NOT visible:**
|
||||
- Debug utility not imported
|
||||
- Check `src/app/layout.tsx` has: `import '@/lib/debug-state';`
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ **Timing Issue - Console.log Too Early**
|
||||
|
||||
**Problem:** You're checking `window.publicMusicState` before it's exposed.
|
||||
|
||||
**Solution:** Wait for page to fully load, then check:
|
||||
|
||||
```javascript
|
||||
// In browser console, type:
|
||||
window.publicMusicState
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```javascript
|
||||
{
|
||||
isPlaying: false,
|
||||
currentSong: null,
|
||||
currentSongIndex: -1,
|
||||
musikData: [],
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 70,
|
||||
isMuted: false,
|
||||
isRepeat: false,
|
||||
isShuffle: false,
|
||||
isLoading: true,
|
||||
isPlayerOpen: false,
|
||||
error: null,
|
||||
playSong: ƒ,
|
||||
togglePlayPause: ƒ,
|
||||
// ... all methods
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ **Alternative Debug Methods**
|
||||
|
||||
If `window.publicMusicState` still undefined, try these:
|
||||
|
||||
#### Method 1: Use Helper Function
|
||||
```javascript
|
||||
// In browser console:
|
||||
window.getMusicState()
|
||||
```
|
||||
|
||||
#### Method 2: Import Directly (in console)
|
||||
```javascript
|
||||
// This won't work in console, but you can add to your component:
|
||||
import { publicMusicState } from '@/state/public/publicMusicState';
|
||||
console.log('Music State:', publicMusicState);
|
||||
```
|
||||
|
||||
#### Method 3: Check from Component
|
||||
Add to any component:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
console.log('Music State:', window.publicMusicState);
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ **Verify Import Chain**
|
||||
|
||||
Check if all files are properly imported:
|
||||
|
||||
```
|
||||
src/app/layout.tsx
|
||||
└─ import '@/lib/debug-state'
|
||||
└─ import { publicMusicState } from '@/state/public/publicMusicState'
|
||||
└─ Exports proxy state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ **Check Browser Console for Errors**
|
||||
|
||||
Look for errors like:
|
||||
- ❌ `Cannot find module '@/state/public/publicMusicState'`
|
||||
- ❌ `publicMusicState is not defined`
|
||||
- ❌ `Failed to load module`
|
||||
|
||||
**If you see these:**
|
||||
- Check TypeScript compilation: `bunx tsc --noEmit`
|
||||
- Check file paths are correct
|
||||
- Restart dev server: `bun run dev`
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ **Manual Test - Add to Component**
|
||||
|
||||
Temporarily add to any page component:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { publicMusicState } from '@/state/public/publicMusicState';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function TestPage() {
|
||||
useEffect(() => {
|
||||
console.log('🎵 Music State:', publicMusicState);
|
||||
console.log('🎵 Is Playing:', publicMusicState.isPlaying);
|
||||
console.log('🎵 Current Song:', publicMusicState.currentSong);
|
||||
}, []);
|
||||
|
||||
return <div>Check console</div>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7️⃣ **Quick Fix - Re-import in Layout**
|
||||
|
||||
If still undefined, add explicit import in `src/app/layout.tsx`:
|
||||
|
||||
```typescript
|
||||
import '@/lib/debug-state'; // Debug state exposure
|
||||
|
||||
// Add this AFTER imports
|
||||
if (typeof window !== 'undefined') {
|
||||
import('@/state/public/publicMusicState').then(({ publicMusicState }) => {
|
||||
(window as any).publicMusicState = publicMusicState.publicMusicState;
|
||||
console.log('✅ Music state manually exposed!');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8️⃣ **Verify State is Working**
|
||||
|
||||
Test state reactivity:
|
||||
|
||||
```javascript
|
||||
// In browser console:
|
||||
window.publicMusicState.volume = 80
|
||||
console.log(window.publicMusicState.volume) // Should log: 80
|
||||
|
||||
// Change state
|
||||
window.publicMusicState.togglePlayer()
|
||||
console.log(window.publicMusicState.isPlayerOpen) // Should log: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9️⃣ **Check Valtio Installation**
|
||||
|
||||
Ensure Valtio is installed:
|
||||
|
||||
```bash
|
||||
bun list valtio
|
||||
```
|
||||
|
||||
Should show: `valtio@1.x.x`
|
||||
|
||||
If not installed:
|
||||
```bash
|
||||
bun install valtio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔟 **Nuclear Option - Re-export**
|
||||
|
||||
Create new file `src/lib/music-debug.ts`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { publicMusicState } from '@/state/public/publicMusicState';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).publicMusicState = publicMusicState;
|
||||
console.log('🎵 Music state exposed!');
|
||||
}
|
||||
|
||||
export { publicMusicState };
|
||||
```
|
||||
|
||||
Then import in layout:
|
||||
```typescript
|
||||
import '@/lib/music-debug';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Working Checklist
|
||||
|
||||
- [ ] Debug utility imported in layout.tsx
|
||||
- [ ] Console shows "[Debug] State exposed" message
|
||||
- [ ] No TypeScript errors
|
||||
- [ ] No console errors about missing modules
|
||||
- [ ] `window.publicMusicState` returns object (not undefined)
|
||||
- [ ] State has all properties (isPlaying, currentSong, etc.)
|
||||
- [ ] State methods are functions (playSong, togglePlayPause, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Expected Console Output
|
||||
|
||||
When page loads, you should see:
|
||||
|
||||
```
|
||||
[Debug] State exposed to window object:
|
||||
✅ window.publicMusicState
|
||||
✅ window.adminNavState
|
||||
✅ window.adminAuthState
|
||||
ℹ️ Type "window.publicMusicState" in console to check state
|
||||
|
||||
[MusicState] Loading musik data...
|
||||
[MusicState] API response: {...}
|
||||
[MusicState] Loaded 2 active songs
|
||||
[MusicState] First song: {judul: 'Celengan Rindu', ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Still Having Issues?
|
||||
|
||||
If `window.publicMusicState` still undefined after trying all above:
|
||||
|
||||
1. **Clear browser cache** - Hard refresh (Ctrl+Shift+R)
|
||||
2. **Restart dev server** - `bun run dev`
|
||||
3. **Check file permissions** - Ensure files are readable
|
||||
4. **Check Next.js config** - Ensure path aliases work
|
||||
5. **Try incognito mode** - Rule out extensions interfering
|
||||
|
||||
---
|
||||
|
||||
Last updated: March 9, 2026
|
||||
191
DEV-INSPECTOR-ANALYSIS.md
Normal file
191
DEV-INSPECTOR-ANALYSIS.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Dev Inspector - Analisis & Rekomendasi untuk Project Desa Darmasaba
|
||||
|
||||
## 📋 Ringkasan Analisis
|
||||
|
||||
Dokumen `dev-inspector-click-to-source.md` **TIDAK dapat diterapkan langsung** ke project ini karena perbedaan arsitektur fundamental.
|
||||
|
||||
## 🔍 Perbedaan Arsitektur
|
||||
|
||||
| Syarat di Dokumen | Project Desa Darmasaba | Status |
|
||||
|-------------------|------------------------|--------|
|
||||
| **Vite sebagai bundler** | Next.js 15 (Webpack/Turbopack) | ❌ Tidak kompatibel |
|
||||
| **Elysia + Vite middlewareMode** | Next.js App Router + Elysia sebagai API handler | ❌ Berbeda |
|
||||
| **React** | ✅ React 19 | ✅ Kompatibel |
|
||||
| **Bun runtime** | ✅ Bun | ✅ Kompatibel |
|
||||
|
||||
## ✅ Solusi: Next.js Sudah Punya Built-in Click-to-Source
|
||||
|
||||
Next.js memiliki fitur **click-to-source bawaan** yang bekerja tanpa setup tambahan:
|
||||
|
||||
### Cara Menggunakan
|
||||
|
||||
1. **Pastikan dalam development mode:**
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
2. **Klik elemen dengan modifier key:**
|
||||
- **macOS**: `Option` + `Click` (atau `⌥` + `Click`)
|
||||
- **Windows/Linux**: `Alt` + `Click`
|
||||
|
||||
3. **File akan terbuka di editor** pada baris dan kolom yang tepat
|
||||
|
||||
### Syarat Agar Berfungsi
|
||||
|
||||
1. **Editor harus ada di PATH**
|
||||
|
||||
VS Code biasanya sudah terdaftar. Jika menggunakan editor lain, set:
|
||||
```bash
|
||||
# Untuk Cursor
|
||||
export EDITOR=cursor
|
||||
|
||||
# Untuk Windsurf
|
||||
export EDITOR=windsurf
|
||||
|
||||
# Untuk Sublime Text
|
||||
export EDITOR=subl
|
||||
```
|
||||
|
||||
2. **Hanya berfungsi di development mode**
|
||||
- Fitur ini otomatis tree-shaken di production
|
||||
- Zero overhead di production build
|
||||
|
||||
3. **Browser DevTools harus terbuka** (beberapa browser memerlukan ini)
|
||||
|
||||
## 🎯 Rekomendasi untuk Project Ini
|
||||
|
||||
### Opsi 1: Gunakan Built-in Next.js (DIREKOMENDASIKAN)
|
||||
|
||||
**Kelebihan:**
|
||||
- ✅ Zero setup
|
||||
- ✅ Maintain oleh Vercel
|
||||
- ✅ Otomatis compatible dengan Next.js updates
|
||||
- ✅ Zero production overhead
|
||||
|
||||
**Kekurangan:**
|
||||
- ⚠️ Hotkey berbeda (`Option+Click` vs `Ctrl+Shift+Cmd+C`)
|
||||
- ⚠️ Tidak ada visual overlay/tooltip seperti di dokumen
|
||||
|
||||
**Cara:**
|
||||
Tidak perlu melakukan apapun - fitur sudah aktif saat `bun run dev`.
|
||||
|
||||
### Opsi 2: Custom Implementation (JIKA DIPERLUKAN)
|
||||
|
||||
Jika ingin visual overlay dan tooltip seperti di dokumen, bisa dibuat custom component dengan pendekatan berbeda:
|
||||
|
||||
#### Arsitektur Alternatif untuk Next.js
|
||||
|
||||
```
|
||||
BUILD TIME (Next.js/Webpack):
|
||||
.tsx/.jsx file
|
||||
→ [Custom Webpack Loader] inject data-inspector-* attributes
|
||||
→ [Next.js internal transform] JSX to React.createElement
|
||||
→ Browser menerima elemen dengan attributes
|
||||
|
||||
RUNTIME (Browser):
|
||||
[SAMA seperti dokumen - DevInspector component]
|
||||
|
||||
BACKEND (Next.js API Route):
|
||||
/__open-in-editor → Bun.spawn([editor, '--goto', 'file:line:col'])
|
||||
```
|
||||
|
||||
#### Komponen yang Dibutuhkan:
|
||||
|
||||
1. **Custom Webpack Loader** (bukan Vite Plugin)
|
||||
- Inject attributes via webpack transform
|
||||
- Taruh di `next.config.ts` webpack config
|
||||
|
||||
2. **DevInspector Component** (sama seperti dokumen)
|
||||
- Browser runtime untuk handle hotkey & klik
|
||||
|
||||
3. **API Route `/__open-in-editor`**
|
||||
- Buat sebagai Next.js API route: `src/app/api/__open-in-editor/route.ts`
|
||||
- HARUS bypass auth middleware
|
||||
|
||||
4. **Conditional Import** (sama seperti dokumen)
|
||||
```tsx
|
||||
const InspectorWrapper = process.env.NODE_ENV === 'development'
|
||||
? (await import('./DevInspector')).DevInspector
|
||||
: ({ children }) => <>{children}</>
|
||||
```
|
||||
|
||||
#### Implementasi Steps:
|
||||
|
||||
Jika Anda ingin melanjutkan dengan custom implementation, berikut steps:
|
||||
|
||||
1. ✅ Buat `src/components/DevInspector.tsx` (copy dari dokumen)
|
||||
2. ⚠️ Buat webpack loader untuk inject attributes (perlu research)
|
||||
3. ✅ Buat API route `src/app/api/__open-in-editor/route.ts`
|
||||
4. ✅ Wrap root layout dengan DevInspector
|
||||
5. ✅ Set `REACT_EDITOR` di `.env`
|
||||
|
||||
**Peringatan:**
|
||||
- Webpack loader lebih kompleks daripada Vite plugin
|
||||
- Mungkin ada edge cases dengan Next.js internals
|
||||
- Perlu maintenance ekstra saat Next.js update
|
||||
|
||||
## 📊 Perbandingan
|
||||
|
||||
| Fitur | Built-in Next.js | Custom Implementation |
|
||||
|-------|------------------|----------------------|
|
||||
| Setup | ✅ Zero | ⚠️ Medium |
|
||||
| Visual Overlay | ❌ Tidak ada | ✅ Ada |
|
||||
| Tooltip | ❌ Tidak ada | ✅ Ada |
|
||||
| Hotkey | `Option+Click` | Custom (bisa disesuaikan) |
|
||||
| Maintenance | ✅ Vercel | ⚠️ Manual |
|
||||
| Compatibility | ✅ Guaranteed | ⚠️ Perlu testing |
|
||||
| Production Impact | ✅ Zero | ✅ Zero (dengan conditional import) |
|
||||
|
||||
## 🎯 Kesimpulan
|
||||
|
||||
**Rekomendasi: Gunakan Built-in Next.js**
|
||||
|
||||
Alasan:
|
||||
1. ✅ Sudah tersedia - tidak perlu setup
|
||||
2. ✅ Lebih stabil - maintain oleh Vercel
|
||||
3. ✅ Lebih simple - tidak ada custom code
|
||||
4. ✅ Future-proof - otomatis update dengan Next.js
|
||||
|
||||
**Custom implementation hanya diperlukan jika:**
|
||||
- Anda sangat membutuhkan visual overlay & tooltip
|
||||
- Anda ingin hotkey yang sama persis (`Ctrl+Shift+Cmd+C`)
|
||||
- Anda punya waktu untuk maintenance
|
||||
|
||||
## 🚀 Quick Start - Built-in Feature
|
||||
|
||||
Untuk menggunakan click-to-source bawaan Next.js:
|
||||
|
||||
1. Jalankan development server:
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
2. Buka browser ke `http://localhost:3000`
|
||||
|
||||
3. Tahan `Option` (macOS) atau `Alt` (Windows/Linux)
|
||||
|
||||
4. Cursor akan berubah menjadi crosshair
|
||||
|
||||
5. Klik elemen mana pun - file akan terbuka di editor
|
||||
|
||||
6. **Opsional**: Set editor di `.env`:
|
||||
```env
|
||||
# .env.local
|
||||
EDITOR=code # atau cursor, windsurf, subl
|
||||
```
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Fitur ini hanya aktif di development mode (`NODE_ENV=development`)
|
||||
- Production build (`bun run build`) otomatis menghilangkan fitur ini
|
||||
- Next.js menggunakan mekanisme yang mirip (source mapping) untuk menentukan lokasi component
|
||||
- Jika editor tidak terbuka, pastikan:
|
||||
- Editor sudah terinstall dan ada di PATH
|
||||
- Browser DevTools terbuka (beberapa browser require ini)
|
||||
- Anda menggunakan development server, bukan production
|
||||
|
||||
## 🔗 Referensi
|
||||
|
||||
- [Next.js Documentation - Launching Editor](https://nextjs.org/docs/app/api-reference/config/next-config-js/reactStrictMode)
|
||||
- [React DevTools - Component Inspection](https://react.dev/learn/react-developer-tools)
|
||||
- [Original Dev Inspector Document](./dev-inspector-click-to-source.md)
|
||||
76
Dockerfile
Normal file
76
Dockerfile
Normal file
@@ -0,0 +1,76 @@
|
||||
# ==============================
|
||||
# Stage 1: Builder
|
||||
# ==============================
|
||||
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* ./
|
||||
|
||||
ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cp .env.example .env || true
|
||||
|
||||
ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x
|
||||
RUN bunx prisma generate
|
||||
|
||||
# Generate API types (opsional)
|
||||
RUN bun run gen:api || echo "tidak ada gen api"
|
||||
|
||||
RUN bun run build
|
||||
|
||||
# ==============================
|
||||
# Stage 2: Runner (Production)
|
||||
# ==============================
|
||||
FROM oven/bun:1-debian AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PRISMA_CLI_BINARY_TARGETS=debian-openssl-3.0.x
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
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 --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/src/lib ./src/lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/next.config.* ./
|
||||
COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
# Create uploads directory with proper permissions
|
||||
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Persistent storage for uploaded files
|
||||
VOLUME ["/app/uploads"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["/app/docker-entrypoint.sh"]
|
||||
24
MIND/PLAN/umkm-module.md
Normal file
24
MIND/PLAN/umkm-module.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Plan: UMKM Module Implementation
|
||||
|
||||
## Goal
|
||||
Implement UMKM, ProdukUmkm, and PenjualanProduk module with CRUD API and Dashboard analytics.
|
||||
|
||||
## Steps
|
||||
1. Update Prisma Schema (already done in file).
|
||||
2. Run database migration and seed data.
|
||||
3. Implement UMKM CRUD API.
|
||||
4. Implement ProdukUmkm CRUD API.
|
||||
5. Implement PenjualanProduk CRUD API.
|
||||
6. Implement Dashboard API (KPI, Summary, Top Produk, Detail Penjualan).
|
||||
7. Register all routers in the ekonomi module.
|
||||
8. Verify with type check and build.
|
||||
|
||||
## Progress
|
||||
- [x] Step 1: Update Prisma Schema
|
||||
- [x] Step 2: Run database migration
|
||||
- [x] Step 3: Implement UMKM CRUD API
|
||||
- [x] Step 4: Implement ProdukUmkm CRUD API
|
||||
- [x] Step 5: Implement PenjualanProduk CRUD API
|
||||
- [x] Step 6: Implement Dashboard API
|
||||
- [x] Step 7: Register routers
|
||||
- [x] Step 8: Verify changes
|
||||
28
MIND/SUMMARY/umkm-module-summary.md
Normal file
28
MIND/SUMMARY/umkm-module-summary.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Summary: UMKM Module Implementation
|
||||
|
||||
## Accomplishments
|
||||
- Successfully migrated the database to include `Umkm`, `ProdukUmkm`, and `PenjualanProduk` tables.
|
||||
- Implemented a complete set of CRUD API endpoints for UMKM, Products, and Sales.
|
||||
- Developed a comprehensive Dashboard API providing KPIs, sales summaries, top products, and detailed stock analytics.
|
||||
- Integrated the new module into the existing `ekonomi` router.
|
||||
- Verified the implementation with `tsc` to ensure type safety.
|
||||
|
||||
## Files Created/Modified
|
||||
### Modified
|
||||
- `prisma/schema.prisma`: Added relations and models.
|
||||
- `src/app/api/[[...slugs]]/_lib/ekonomi/index.ts`: Registered new routers.
|
||||
|
||||
### Created
|
||||
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/`: CRUD for UMKM.
|
||||
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/`: CRUD for Products.
|
||||
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/penjualan/`: CRUD for Sales with stock management.
|
||||
- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/`: Analytics endpoints.
|
||||
|
||||
## Stock Management Logic
|
||||
- Creating a sale decrements product stock.
|
||||
- Updating a sale adjusts stock based on the difference in quantity.
|
||||
- Deleting a sale increments stock back.
|
||||
|
||||
## Next Steps
|
||||
- Implement frontend UI for the UMKM module.
|
||||
- Add more comprehensive tests for the stock management logic.
|
||||
173
MUSIK_CREATE_ANALYSIS.md
Normal file
173
MUSIK_CREATE_ANALYSIS.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Musik Desa - Create Feature Analysis
|
||||
|
||||
## Error Summary
|
||||
**Error**: `ERR_BLOCKED_BY_CLIENT` saat create musik di staging environment
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### 1. **CORS Configuration Issue** (Primary)
|
||||
File: `src/app/api/[[...slugs]]/route.ts`
|
||||
|
||||
The CORS configuration has specific origins listed:
|
||||
```typescript
|
||||
const corsConfig = {
|
||||
origin: [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||
"*",
|
||||
],
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Problem**: The wildcard `*` is at the end, but some browsers don't respect it when `credentials: true` is set.
|
||||
|
||||
### 2. **API Fetch Base URL** (Secondary)
|
||||
File: `src/lib/api-fetch.ts`
|
||||
|
||||
```typescript
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
||||
```
|
||||
|
||||
**Problem**:
|
||||
- In staging, this might still default to `http://localhost:3000`
|
||||
- Mixed content (HTTPS frontend → HTTP API) gets blocked by browsers
|
||||
- The `NEXT_PUBLIC_BASE_URL` environment variable might not be set in staging
|
||||
|
||||
### 3. **File Storage Upload Path** (Tertiary)
|
||||
File: `src/app/api/[[...slugs]]/_lib/fileStorage/_lib/create.ts`
|
||||
|
||||
```typescript
|
||||
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
|
||||
```
|
||||
|
||||
**Problem**: If `WIBU_UPLOAD_DIR` is not set or points to a non-writable location, uploads will fail silently.
|
||||
|
||||
## Solution
|
||||
|
||||
### Fix 1: Update CORS Configuration
|
||||
**File**: `src/app/api/[[...slugs]]/route.ts`
|
||||
|
||||
```typescript
|
||||
// Move wildcard to first position and ensure it works with credentials
|
||||
const corsConfig = {
|
||||
origin: [
|
||||
"*", // Allow all origins (for staging flexibility)
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||
"https://desa-darmasaba-stg.wibudev.com"
|
||||
],
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept"],
|
||||
exposedHeaders: ["Content-Range", "X-Content-Range"],
|
||||
maxAge: 86400, // 24 hours
|
||||
credentials: true,
|
||||
};
|
||||
```
|
||||
|
||||
### Fix 2: Add Environment Variable Validation
|
||||
**File**: `.env.example` (update)
|
||||
|
||||
```bash
|
||||
# Application Configuration
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
|
||||
# For staging/production, set this to your actual domain
|
||||
# NEXT_PUBLIC_BASE_URL=https://cld-dkr-desa-darmasaba-stg.wibudev.com
|
||||
```
|
||||
|
||||
### Fix 3: Update API Fetch to Handle Relative URLs
|
||||
**File**: `src/lib/api-fetch.ts`
|
||||
|
||||
```typescript
|
||||
import { AppServer } from '@/app/api/[[...slugs]]/route'
|
||||
import { treaty } from '@elysiajs/eden'
|
||||
|
||||
// Use relative URL for better deployment flexibility
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || '/'
|
||||
|
||||
const ApiFetch = treaty<AppServer>(BASE_URL)
|
||||
|
||||
export default ApiFetch
|
||||
```
|
||||
|
||||
### Fix 4: Add Error Handling in Create Page
|
||||
**File**: `src/app/admin/(dashboard)/musik/create/page.tsx`
|
||||
|
||||
Add better error logging to diagnose issues:
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async () => {
|
||||
// ... validation ...
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Upload cover image
|
||||
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: coverFile,
|
||||
name: coverFile.name,
|
||||
});
|
||||
|
||||
if (!coverRes.data?.data?.id) {
|
||||
console.error('Cover upload failed:', coverRes);
|
||||
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||
}
|
||||
|
||||
// ... rest of the code ...
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating musik:', {
|
||||
error,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
toast.error('Terjadi kesalahan saat membuat musik');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Local Development
|
||||
- [ ] Test create musik with cover image and audio file
|
||||
- [ ] Verify CORS headers in browser DevTools Network tab
|
||||
- [ ] Check that file uploads are saved to correct directory
|
||||
|
||||
### Staging Environment
|
||||
- [ ] Set `NEXT_PUBLIC_BASE_URL` to staging domain
|
||||
- [ ] Verify HTTPS is used for all API calls
|
||||
- [ ] Check browser console for mixed content warnings
|
||||
- [ ] Verify `WIBU_UPLOAD_DIR` is set and writable
|
||||
- [ ] Test create musik end-to-end
|
||||
|
||||
## Additional Notes
|
||||
|
||||
### ERR_BLOCKED_BY_CLIENT Common Causes:
|
||||
1. **CORS policy blocking** - Most likely cause
|
||||
2. **Ad blockers** - Can block certain API endpoints
|
||||
3. **Mixed content** - HTTPS page making HTTP requests
|
||||
4. **Content Security Policy (CSP)** - Restrictive CSP headers
|
||||
5. **Browser extensions** - Privacy/security extensions blocking requests
|
||||
|
||||
### Debugging Steps:
|
||||
1. Open browser DevTools → Network tab
|
||||
2. Try to create musik
|
||||
3. Look for failed requests (red status)
|
||||
4. Check the "Headers" tab for:
|
||||
- Request URL (should be correct domain)
|
||||
- Response headers (should have `Access-Control-Allow-Origin`)
|
||||
- Status code (4xx/5xx indicates server-side issue)
|
||||
5. Check browser console for CORS errors
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. **Immediate**: Update CORS configuration to allow staging domain
|
||||
2. **Short-term**: Add proper environment variable validation
|
||||
3. **Long-term**: Implement proper error boundaries and logging
|
||||
File diff suppressed because it is too large
Load Diff
34
QWEN.md
34
QWEN.md
@@ -229,4 +229,36 @@ Common issues and solutions:
|
||||
3. Test database changes with `bunx prisma db push`
|
||||
4. Use the integrated Swagger docs at `/api/docs` for API testing
|
||||
5. Check environment variables are properly configured
|
||||
6. Verify responsive design on different screen sizes
|
||||
6. Verify responsive design on different screen sizes
|
||||
|
||||
## Qwen Added Memories
|
||||
- **GitHub Workflows**: Project ini memiliki workflow GitHub Action untuk deployment. User akan menangani workflow secara manual di GitHub.
|
||||
- **Deployment Workflow Sistematis**:
|
||||
1. **Version Bump** - Update `version` di `package.json` sebelum deploy (ikuti semver: major.minor.patch)
|
||||
2. **Commit** - Commit perubahan + version bump dengan pesan yang jelas
|
||||
3. **Push ke Branch** - Push ke branch target (biasanya `stg` untuk staging atau `prod` untuk production)
|
||||
4. **Trigger publish.yml** - Gunakan GitHub API dengan: `ref: main`, `stack_env: stg`, `tag: <versi-dari-package.json>`
|
||||
5. **Tunggu publish selesai** - Workflow harus completed baru lanjut ke re-pull
|
||||
6. **Trigger re-pull.yml** - Gunakan GitHub API dengan: `ref: main`, `stack_name: desa-darmasaba`, `stack_env: stg`
|
||||
|
||||
Branch deployment: `stg` (staging) atau `prod` (production)
|
||||
Version format di package.json: `"version": "major.minor.patch"`
|
||||
|
||||
- **Auto Deploy After Push to deploy/stg**: Setelah push ke `deploy/stg`, otomatis trigger deployment workflow:
|
||||
1. Bump version di `package.json` (increment patch)
|
||||
2. Commit version bump
|
||||
3. Trigger `publish.yml` via GitHub API: `ref=main`, `stack_env=stg`, `tag=<version>`
|
||||
4. Polling sampai publish workflow completed
|
||||
5. Trigger `re-pull.yml` via GitHub API: `ref=main`, `stack_name=desa-darmasaba`, `stack_env=stg`
|
||||
|
||||
Cara trigger workflow via GitHub CLI:
|
||||
```bash
|
||||
# Trigger publish
|
||||
gh workflow run publish.yml --ref main -f stack_env=stg -f tag=<version>
|
||||
|
||||
# Cek status (polling)
|
||||
gh run watch <run_id>
|
||||
|
||||
# Trigger re-pull
|
||||
gh workflow run re-pull.yml --ref main -f stack_name=desa-darmasaba -f stack_env=stg
|
||||
```
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
# Security Fixes Implementation
|
||||
|
||||
**Date:** March 9, 2026
|
||||
**Issue:** SECURITY VULNERABILITIES - CRITICAL (from QUALITY_CONTROL_REPORT.md)
|
||||
**Status:** ✅ COMPLETED
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Vulnerabilities Fixed
|
||||
|
||||
### 3.1 ✅ OTP Sent via POST Request (Not GET)
|
||||
|
||||
**Problem:** OTP code was exposed in URL query strings, which are:
|
||||
- Logged by web servers and proxies
|
||||
- Visible in browser history
|
||||
- Potentially intercepted in man-in-the-middle attacks
|
||||
|
||||
**Solution:** Created secure WhatsApp service that uses POST request
|
||||
|
||||
**Files Changed:**
|
||||
1. `src/lib/whatsapp.ts` - ✅ NEW - Secure WhatsApp OTP service
|
||||
2. `src/app/api/[[...slugs]]/_lib/auth/login/route.ts` - Updated to use new service
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// OLD (Insecure) - GET with OTP in URL
|
||||
const waRes = await fetch(
|
||||
`https://wa.wibudev.com/code?nom=${nomor}&text=Kode OTP: ${codeOtp}`
|
||||
);
|
||||
|
||||
// NEW (Secure) - POST with OTP reference
|
||||
const waResult = await sendWhatsAppOTP({
|
||||
nomor: nomor,
|
||||
otpId: otpRecord.id, // Send reference, not actual OTP
|
||||
message: formatOTPMessage(codeOtp),
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ OTP not exposed in URL
|
||||
- ✅ Not logged by servers/proxies
|
||||
- ✅ Not visible in browser history
|
||||
- ✅ Uses proper HTTP method for sensitive operations
|
||||
|
||||
---
|
||||
|
||||
### 3.2 ✅ Strong Session Password Enforcement
|
||||
|
||||
**Problem:** Default fallback password in production creates security vulnerability
|
||||
|
||||
**Solution:** Enforce SESSION_PASSWORD environment variable with validation
|
||||
|
||||
**Files Changed:**
|
||||
- `src/lib/session.ts` - Added runtime validation
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// Validate SESSION_PASSWORD environment variable
|
||||
if (!process.env.SESSION_PASSWORD) {
|
||||
throw new Error(
|
||||
'SESSION_PASSWORD environment variable is required. ' +
|
||||
'Please set a strong password (min 32 characters) in your .env file.'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate password length for security
|
||||
if (process.env.SESSION_PASSWORD.length < 32) {
|
||||
throw new Error(
|
||||
'SESSION_PASSWORD must be at least 32 characters long for security. ' +
|
||||
'Please use a strong random password.'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ No default/fallback password
|
||||
- ✅ Enforces strong password (min 32 chars)
|
||||
- ✅ Fails fast on startup if not configured
|
||||
- ✅ Clear error messages for developers
|
||||
|
||||
**Migration:**
|
||||
Add to your `.env.local`:
|
||||
```bash
|
||||
# Generate a strong random password (min 32 characters)
|
||||
SESSION_PASSWORD="your-super-secure-random-password-at-least-32-chars"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 ✅ Input Validation with Zod
|
||||
|
||||
**Problem:** No input validation - direct type casting without sanitization
|
||||
|
||||
**Solution:** Comprehensive Zod validation schemas with HTML sanitization
|
||||
|
||||
**Files Created:**
|
||||
1. `src/lib/validations/index.ts` - ✅ NEW - Centralized validation schemas
|
||||
2. `src/lib/sanitizer.ts` - ✅ NEW - HTML/content sanitization utilities
|
||||
|
||||
**Files Changed:**
|
||||
- `src/app/api/[[...slugs]]/_lib/desa/berita/create.ts` - Added validation + sanitization
|
||||
|
||||
**Validation Schemas:**
|
||||
```typescript
|
||||
// Berita validation
|
||||
export const createBeritaSchema = z.object({
|
||||
judul: z.string().min(5).max(255),
|
||||
deskripsi: z.string().min(10).max(500),
|
||||
content: z.string().min(50),
|
||||
kategoriBeritaId: z.string().cuid(),
|
||||
imageId: z.string().cuid(),
|
||||
imageIds: z.array(z.string().cuid()).optional(),
|
||||
linkVideo: z.string().url().optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
// Login validation
|
||||
export const loginRequestSchema = z.object({
|
||||
nomor: z.string().min(10).max(15).regex(/^[0-9]+$/),
|
||||
});
|
||||
|
||||
// OTP verification
|
||||
export const otpVerificationSchema = z.object({
|
||||
nomor: z.string().min(10).max(15),
|
||||
kodeId: z.string().cuid(),
|
||||
otp: z.string().length(6).regex(/^[0-9]+$/),
|
||||
});
|
||||
```
|
||||
|
||||
**Sanitization:**
|
||||
```typescript
|
||||
// HTML sanitization to prevent XSS
|
||||
const sanitizedContent = sanitizeHtml(validated.content);
|
||||
|
||||
// YouTube URL sanitization
|
||||
const sanitizedLinkVideo = validated.linkVideo
|
||||
? sanitizeYouTubeUrl(validated.linkVideo)
|
||||
: null;
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Type-safe validation with Zod
|
||||
- ✅ Clear error messages for users
|
||||
- ✅ HTML sanitization prevents XSS attacks
|
||||
- ✅ URL validation prevents malicious links
|
||||
- ✅ Centralized schemas for consistency
|
||||
|
||||
---
|
||||
|
||||
## 📋 Additional Security Improvements
|
||||
|
||||
### Error Handling
|
||||
|
||||
All API endpoints now properly handle validation errors:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const validated = createBeritaSchema.parse(context.body);
|
||||
// ... process data
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.constructor.name === 'ZodError') {
|
||||
const zodError = error as import('zod').ZodError;
|
||||
return {
|
||||
success: false,
|
||||
message: "Validasi gagal",
|
||||
errors: zodError.errors.map(e => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
### Cleanup on Failure
|
||||
|
||||
OTP records are cleaned up if WhatsApp delivery fails:
|
||||
|
||||
```typescript
|
||||
if (waResult.status !== "success") {
|
||||
await prisma.kodeOtp.delete({
|
||||
where: { id: otpRecord.id },
|
||||
}).catch(() => {});
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Gagal mengirim kode verifikasi" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
Run TypeScript check to ensure no errors:
|
||||
|
||||
```bash
|
||||
bunx tsc --noEmit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Security Metrics
|
||||
|
||||
| Metric | Before | After | Improvement |
|
||||
|--------|--------|-------|-------------|
|
||||
| OTP in URL | ✅ Yes | ❌ No | ✅ 100% |
|
||||
| Session Password | ⚠️ Optional | ✅ Required | ✅ 100% |
|
||||
| Input Validation | ❌ None | ✅ Zod | ✅ 100% |
|
||||
| HTML Sanitization | ❌ None | ✅ Yes | ✅ 100% |
|
||||
| Validation Schemas | ❌ None | ✅ 7 schemas | ✅ New |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
### Immediate (Recommended)
|
||||
1. **Update other auth routes** - Apply same pattern to:
|
||||
- `src/app/api/auth/register/route.ts`
|
||||
- `src/app/api/auth/resend/route.ts`
|
||||
- `src/app/api/auth/send-otp-register/route.ts`
|
||||
|
||||
2. **Add more validation schemas** for:
|
||||
- Update berita
|
||||
- Delete operations
|
||||
- Other CRUD endpoints
|
||||
|
||||
3. **Add rate limiting** for:
|
||||
- Login attempts
|
||||
- OTP requests
|
||||
- Password reset
|
||||
|
||||
### Short-term
|
||||
1. **Add CSRF protection** for state-changing operations
|
||||
2. **Implement request logging** for security audits
|
||||
3. **Add security headers** (CSP, X-Frame-Options, etc.)
|
||||
4. **Set up security monitoring** (failed login attempts, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
New documentation files created:
|
||||
- `src/lib/whatsapp.ts` - WhatsApp service documentation
|
||||
- `src/lib/validations/index.ts` - Validation schemas documentation
|
||||
- `src/lib/sanitizer.ts` - Sanitization utilities documentation
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
- [x] OTP transmission secured (POST instead of GET)
|
||||
- [x] Session password enforced (no fallback)
|
||||
- [x] Input validation implemented (Zod)
|
||||
- [x] HTML sanitization added (XSS prevention)
|
||||
- [x] Error handling improved
|
||||
- [x] TypeScript compilation passes
|
||||
- [x] Documentation updated
|
||||
|
||||
---
|
||||
|
||||
**Security Status:** 🟢 SIGNIFICANTLY IMPROVED
|
||||
|
||||
All critical security vulnerabilities identified in the quality control report have been addressed. The application now follows security best practices for:
|
||||
- Sensitive data transmission
|
||||
- Session management
|
||||
- Input validation
|
||||
- XSS prevention
|
||||
@@ -1,244 +0,0 @@
|
||||
# State Management Refactoring Summary
|
||||
|
||||
**Date:** March 9, 2026
|
||||
**Issue:** STATE MANAGEMENT CHAOS - CRITICAL (from QUALITY_CONTROL_REPORT.md)
|
||||
**Status:** ✅ COMPLETED
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The codebase had multiple state management solutions used inconsistently:
|
||||
- Valtio (primary but not documented)
|
||||
- React Context (MusicContext)
|
||||
- AGENTS.md mentioned Jotai (incorrect documentation)
|
||||
- No clear separation between admin and public state
|
||||
- Tight coupling between domains
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. **Created Organized State Structure**
|
||||
|
||||
```
|
||||
src/state/
|
||||
├── admin/ # Admin dashboard state
|
||||
│ ├── index.ts # Admin state exports
|
||||
│ ├── adminNavState.ts # ✅ NEW - Navigation state
|
||||
│ ├── adminAuthState.ts # ✅ NEW - Authentication state
|
||||
│ ├── adminFormState.ts # ✅ NEW - Form/image state
|
||||
│ └── adminModuleState.ts # ✅ NEW - Module-specific state
|
||||
│
|
||||
├── public/ # Public pages state
|
||||
│ ├── index.ts # Public state exports
|
||||
│ ├── publicNavState.ts # ✅ NEW - Navigation state
|
||||
│ └── publicMusicState.ts # ✅ NEW - Music player state
|
||||
│
|
||||
├── darkModeStore.ts # Existing (kept as-is)
|
||||
└── index.ts # ✅ NEW - Central exports
|
||||
```
|
||||
|
||||
### 2. **Refactored MusicContext to Valtio**
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
// Pure React Context with useState
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentSong, setCurrentSong] = useState<Musik | null>(null);
|
||||
// ... 300+ lines of Context logic
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
// Valtio state with React Context wrapper
|
||||
export const publicMusicState = proxy<{
|
||||
isPlaying: boolean;
|
||||
currentSong: Musik | null;
|
||||
// ... all state
|
||||
playSong: (song: Musik) => void;
|
||||
togglePlayPause: () => void;
|
||||
// ... all methods
|
||||
}>({...});
|
||||
|
||||
// Backward compatible Context wrapper
|
||||
export function MusicProvider({ children }) {
|
||||
// Uses Valtio state internally
|
||||
}
|
||||
```
|
||||
|
||||
**Files Changed:**
|
||||
- `src/app/context/MusicContext.tsx` - Refactored to use Valtio
|
||||
- `src/app/context/MusicContext.ts` - ✅ NEW - Compatibility layer
|
||||
- `src/app/context/MusicProvider.tsx` - ✅ NEW - Provider implementation
|
||||
- `src/state/public/publicMusicState.ts` - ✅ NEW - Valtio state
|
||||
|
||||
### 3. **Updated Legacy Files for Backward Compatibility**
|
||||
|
||||
All existing state files now re-export from new structure:
|
||||
|
||||
```typescript
|
||||
// src/state/state-nav.ts (OLD - kept for compatibility)
|
||||
import { adminNavState } from './admin/adminNavState';
|
||||
export const stateNav = adminNavState;
|
||||
export default stateNav;
|
||||
|
||||
// src/store/authStore.ts (OLD - kept for compatibility)
|
||||
import { adminAuthState } from '../state/admin/adminAuthState';
|
||||
export const authStore = adminAuthState;
|
||||
export default authStore;
|
||||
|
||||
// src/state/state-list-image.ts (OLD - kept for compatibility)
|
||||
import { adminFormState } from './admin/adminFormState';
|
||||
export const stateListImage = adminFormState;
|
||||
export default stateListImage;
|
||||
```
|
||||
|
||||
### 4. **Fixed Documentation Mismatch**
|
||||
|
||||
**Updated AGENTS.md:**
|
||||
- ✅ Changed "Jotai" to "Valtio"
|
||||
- ✅ Added state structure diagram
|
||||
- ✅ Added usage examples
|
||||
- ✅ Updated file organization
|
||||
|
||||
### 5. **Created Comprehensive Documentation**
|
||||
|
||||
**New File:** `docs/STATE_MANAGEMENT.md`
|
||||
|
||||
Contains:
|
||||
- Overview of Valtio usage
|
||||
- State structure explanation
|
||||
- Basic usage examples
|
||||
- Domain-specific state guide
|
||||
- Async operations pattern
|
||||
- Best practices (DO/DON'T)
|
||||
- Migration guide from legacy state
|
||||
- Troubleshooting tips
|
||||
|
||||
---
|
||||
|
||||
## Benefits
|
||||
|
||||
### ✅ Clear Separation of Concerns
|
||||
- Admin state: `/admin` routes only
|
||||
- Public state: `/darmasaba` routes only
|
||||
- No more cross-domain coupling
|
||||
|
||||
### ✅ Consistent Pattern
|
||||
- All state uses Valtio
|
||||
- Same pattern across entire codebase
|
||||
- Methods defined within state objects
|
||||
|
||||
### ✅ Backward Compatible
|
||||
- All existing imports still work
|
||||
- No breaking changes to existing code
|
||||
- Gradual migration path
|
||||
|
||||
### ✅ Better Documentation
|
||||
- AGENTS.md now accurate (Valtio, not Jotai)
|
||||
- Comprehensive guide in docs/STATE_MANAGEMENT.md
|
||||
- Clear usage examples
|
||||
|
||||
### ✅ Type Safe
|
||||
- Full TypeScript support
|
||||
- All state properly typed
|
||||
- No `any` types in new code
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For New Code
|
||||
|
||||
```typescript
|
||||
// Import admin state
|
||||
import { adminNavState, useAdminNav } from '@/state';
|
||||
|
||||
// Use in component
|
||||
function MyComponent() {
|
||||
const { mobileOpen, toggleMobile } = useAdminNav();
|
||||
return <Button onClick={toggleMobile}>Menu</Button>;
|
||||
}
|
||||
|
||||
// Use outside component
|
||||
adminNavState.mobileOpen = true;
|
||||
```
|
||||
|
||||
### For Existing Code
|
||||
|
||||
No changes needed! All existing imports continue to work:
|
||||
|
||||
```typescript
|
||||
// Still works
|
||||
import stateNav from '@/state/state-nav';
|
||||
import { authStore } from '@/store/authStore';
|
||||
import { useMusic } from '@/app/context/MusicContext';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
All TypeScript checks pass:
|
||||
```bash
|
||||
bunx tsc --noEmit
|
||||
# ✅ No errors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Created
|
||||
|
||||
1. `src/state/admin/index.ts`
|
||||
2. `src/state/admin/adminNavState.ts`
|
||||
3. `src/state/admin/adminAuthState.ts`
|
||||
4. `src/state/admin/adminFormState.ts`
|
||||
5. `src/state/admin/adminModuleState.ts`
|
||||
6. `src/state/public/index.ts`
|
||||
7. `src/state/public/publicNavState.ts`
|
||||
8. `src/state/public/publicMusicState.ts`
|
||||
9. `src/state/index.ts`
|
||||
10. `src/app/context/MusicContext.ts`
|
||||
11. `src/app/context/MusicProvider.tsx`
|
||||
12. `docs/STATE_MANAGEMENT.md`
|
||||
13. `STATE_REFACTORING_SUMMARY.md` (this file)
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `src/state/state-nav.ts` - Re-export from new structure
|
||||
2. `src/store/authStore.ts` - Re-export from new structure
|
||||
3. `src/state/state-list-image.ts` - Re-export from new structure
|
||||
4. `src/state/state-layanan.ts` - Simplified
|
||||
5. `src/state/darkModeStore.ts` - Updated docs
|
||||
6. `src/app/context/MusicContext.tsx` - Refactored to use Valtio
|
||||
7. `AGENTS.md` - Fixed Jotai → Valtio documentation
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Optional)
|
||||
|
||||
Future improvements that can be made:
|
||||
|
||||
1. **Gradually migrate** old state files to new structure
|
||||
2. **Remove legacy files** once all usages are updated
|
||||
3. **Add unit tests** for state management
|
||||
4. **Add state persistence** for admin preferences
|
||||
5. **Implement state hydration** for SSR optimization
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The state management refactoring is **COMPLETE**. All issues identified in the quality control report have been addressed:
|
||||
|
||||
- ✅ Single state management solution (Valtio)
|
||||
- ✅ Clear separation between admin and public domains
|
||||
- ✅ Documentation updated (AGENTS.md)
|
||||
- ✅ Comprehensive guide created (docs/STATE_MANAGEMENT.md)
|
||||
- ✅ Backward compatible (no breaking changes)
|
||||
- ✅ TypeScript compilation passes
|
||||
|
||||
The codebase now has a **consistent, well-documented, and maintainable** state management structure.
|
||||
678
STRUKTUR-PROJEK.md
Normal file
678
STRUKTUR-PROJEK.md
Normal file
@@ -0,0 +1,678 @@
|
||||
# Dokumentasi Struktur Proyek - Desa Darmasaba
|
||||
|
||||
## 1. Ringkasan Proyek
|
||||
|
||||
**Desa Darmasaba** adalah aplikasi web komprehensif untuk layanan pemerintahan desa di Desa Darmasaba, Kabupaten Badung, Bali. Aplikasi ini berfungsi sebagai platform digital untuk layanan pemerintah, informasi publik, dan keterlibatan masyarakat.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Kategori | Teknologi |
|
||||
|----------|-----------|
|
||||
| **Framework Frontend** | Next.js 15 dengan App Router |
|
||||
| **Bahasa** | TypeScript (strict mode) |
|
||||
| **Styling** | Mantine UI v7/v8 + Custom CSS |
|
||||
| **Backend API** | Elysia.js (high-performance TypeScript framework) |
|
||||
| **Database** | PostgreSQL |
|
||||
| **ORM** | Prisma 6.3.1 |
|
||||
| **Runtime** | Bun |
|
||||
| **State Management** | Jotai + Valtio + SWR |
|
||||
| **Autentikasi** | iron-session + JWT |
|
||||
| **File Storage** | Seafile |
|
||||
| **Rich Text Editor** | TipTap |
|
||||
| **Charts** | Recharts + Chart.js |
|
||||
| **Maps** | Leaflet + react-leaflet |
|
||||
| **UI Components** | Mantine, PrimeReact, Framer Motion |
|
||||
| **Validasi** | Zod |
|
||||
| **Testing** | Vitest (unit), Playwright (E2E) |
|
||||
| **Deployment** | Docker + GitHub Actions + Portainer |
|
||||
| **Registry** | GitHub Container Registry (GHCR) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Struktur Direktori
|
||||
|
||||
```
|
||||
desa-darmasaba/
|
||||
├── .github/workflows/ # GitHub Actions CI/CD
|
||||
│ ├── docker-publish.yml # Auto build & push saat tag v*
|
||||
│ ├── publish.yml # Manual build & push ke GHCR
|
||||
│ ├── re-pull.yml # Manual re-pull image di Portainer
|
||||
│ └── script/ # Script deployment
|
||||
│
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Database schema (2413 baris, 100+ model)
|
||||
│ ├── seed.ts # Database seeder utama
|
||||
│ └── _seeder_list/ # Data seed per modul
|
||||
│ ├── desa/ # Seed berita, gallery, layanan, dll
|
||||
│ ├── ekonomi/ # Seed APBDes, demografi, dll
|
||||
│ ├── inovasi/ # Seed ide inovatif, desa digital
|
||||
│ ├── keamanan/ # Seed keamanan, kontak darurat
|
||||
│ ├── kesehatan/ # Seed fasilitas kesehatan, posyandu
|
||||
│ ├── kependudukan/ # Seed data penduduk
|
||||
│ ├── lingkungan/ # Seed lingkungan desa
|
||||
│ ├── pendidikan/ # Seed sekolah, beasiswa
|
||||
│ ├── ppid/ # Seed PPID
|
||||
│ └── landing-page/ # Seed landing page
|
||||
│
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── _com/ # Komponen global (SplashScreen, WebVitals)
|
||||
│ │ ├── admin/ # Panel administrasi (protected)
|
||||
│ │ │ ├── _com/ # Komponen admin shared
|
||||
│ │ │ ├── (dashboard)/ # Dashboard admin dengan route groups
|
||||
│ │ │ │ ├── _com/ # Komponen dashboard shared
|
||||
│ │ │ │ ├── _state/ # State khusus dashboard
|
||||
│ │ │ │ ├── _utils/ # Utilitas dashboard
|
||||
│ │ │ │ ├── auth/ # Autentikasi admin
|
||||
│ │ │ │ ├── desa/ # Admin: berita, gallery, profil, layanan
|
||||
│ │ │ │ ├── ekonomi/ # Admin: APBDes, demografi, BUMDes
|
||||
│ │ │ │ ├── inovasi/ # Admin: ide inovatif, desa digital
|
||||
│ │ │ │ ├── keamanan/ # Admin: keamanan, kontak darurat
|
||||
│ │ │ │ ├── kependudukan/# Admin: banjar, agama, umur, migrasi
|
||||
│ │ │ │ ├── kesehatan/ # Admin: puskesmas, posyandu, wabah
|
||||
│ │ │ │ ├── landing-page/# Admin: konten landing page
|
||||
│ │ │ │ ├── lingkungan/ # Admin: konservasi, sampah, penghijauan
|
||||
│ │ │ │ ├── musik/ # Admin: musik desa
|
||||
│ │ │ │ ├── pendidikan/ # Admin: sekolah, beasiswa, perpustakaan
|
||||
│ │ │ │ ├── ppid/ # Admin: PPID, IKM, permohonan
|
||||
│ │ │ │ └── user&role/ # Admin: manajemen user & role
|
||||
│ │ │ ├── auth/ # Halaman login admin
|
||||
│ │ │ ├── csv/ # Upload/demo CSV
|
||||
│ │ │ ├── images/ # Manajemen gambar
|
||||
│ │ │ └── upload-demo/ # Demo upload
|
||||
│ │ │
|
||||
│ │ ├── api/ # API routes (Elysia.js)
|
||||
│ │ │ ├── [[...slugs]]/ # Catch-all route untuk Elysia
|
||||
│ │ │ │ ├── _lib/ # Modul API per domain
|
||||
│ │ │ │ │ ├── auth/ # Autentikasi API
|
||||
│ │ │ │ │ ├── desa/ # API modul desa
|
||||
│ │ │ │ │ ├── ekonomi/ # API modul ekonomi
|
||||
│ │ │ │ │ ├── fileStorage/ # API file storage
|
||||
│ │ │ │ │ ├── inovasi/ # API modul inovasi
|
||||
│ │ │ │ │ ├── keamanan/# API modul keamanan
|
||||
│ │ │ │ │ ├── kependudukan/ # API modul kependudukan
|
||||
│ │ │ │ │ ├── kesehatan/ # API modul kesehatan
|
||||
│ │ │ │ │ ├── landing_page/ # API landing page
|
||||
│ │ │ │ │ ├── lingkungan/ # API modul lingkungan
|
||||
│ │ │ │ │ ├── pendidikan/ # API modul pendidikan
|
||||
│ │ │ │ │ ├── ppid/ # API modul PPID
|
||||
│ │ │ │ │ ├── search/ # API pencarian global
|
||||
│ │ │ │ │ └── user/ # API user management
|
||||
│ │ │ │ └── route.ts # Entry point Elysia server
|
||||
│ │ │ ├── admin/ # API khusus admin
|
||||
│ │ │ ├── auth/ # API autentikasi
|
||||
│ │ │ ├── health/ # Health check endpoint
|
||||
│ │ │ ├── layout/ # API layout
|
||||
│ │ │ ├── news/ # API berita
|
||||
│ │ │ ├── subscribe/ # API subscription (email)
|
||||
│ │ │ └── tts/ # Text-to-Speech (ElevenLabs)
|
||||
│ │ │
|
||||
│ │ ├── context/ # React contexts
|
||||
│ │ │ └── MusicContext.tsx # Context untuk pemutar musik
|
||||
│ │ │
|
||||
│ │ ├── darmasaba/ # Halaman publik (front-facing)
|
||||
│ │ │ ├── _com/ # Komponen shared publik
|
||||
│ │ │ │ ├── main-page/ # Komponen halaman utama
|
||||
│ │ │ │ ├── Navbar.tsx # Navigasi utama
|
||||
│ │ │ │ ├── Footer.tsx # Footer
|
||||
│ │ │ │ ├── FixedPlayerBar.tsx # Music player bar
|
||||
│ │ │ │ ├── LoadDataFirstClient.tsx # Data prefetching
|
||||
│ │ │ │ ├── NewsReader.tsx # Component pembaca berita
|
||||
│ │ │ │ ├── globalSearch.tsx # Pencarian global
|
||||
│ │ │ │ └── scrollToTopButton.tsx
|
||||
│ │ │ ├── (pages)/ # Halaman publik utama
|
||||
│ │ │ │ ├── desa/ # Halaman: profil, berita, gallery, layanan
|
||||
│ │ │ │ ├── ekonomi/ # Halaman: APBDes, BUMDes, demografi
|
||||
│ │ │ │ ├── inovasi/ # Halaman: inovasi desa
|
||||
│ │ │ │ ├── keamanan/ # Halaman: keamanan lingkungan
|
||||
│ │ │ │ ├── kependudukan/# Halaman: data penduduk
|
||||
│ │ │ │ ├── kesehatan/ # Halaman: fasilitas kesehatan
|
||||
│ │ │ │ ├── lingkungan/ # Halaman: lingkungan desa
|
||||
│ │ │ │ ├── module/ # Halaman modul tambahan
|
||||
│ │ │ │ ├── musik/ # Halaman: musik desa
|
||||
│ │ │ │ ├── pendidikan/ # Halaman: pendidikan
|
||||
│ │ │ │ └── ppid/ # Halaman: PPID publik
|
||||
│ │ │ ├── (tambahan)/ # Halaman tambahan
|
||||
│ │ │ ├── layout.tsx # Layout utama publik
|
||||
│ │ │ └── page.tsx # Landing page utama
|
||||
│ │ │
|
||||
│ │ ├── login/ # Halaman login
|
||||
│ │ ├── registrasi/ # Halaman registrasi
|
||||
│ │ ├── waiting-room/ # Halaman waiting room
|
||||
│ │ ├── terms-of-service/ # Halaman syarat layanan
|
||||
│ │ ├── test-upload/ # Halaman tes upload
|
||||
│ │ ├── validasi/ # Halaman validasi
|
||||
│ │ ├── coba/ # Halaman percobaan
|
||||
│ │ ├── percobaan/ # Halaman percobaan lainnya
|
||||
│ │ ├── layout.tsx # Root layout (MantineProvider)
|
||||
│ │ ├── page.tsx # Root page
|
||||
│ │ ├── error.tsx # Error boundary
|
||||
│ │ ├── not-found.tsx # 404 page
|
||||
│ │ ├── globals.css # Global styles
|
||||
│ │ └── favicon.ico
|
||||
│ │
|
||||
│ ├── components/
|
||||
│ │ └── admin/ # Komponen admin reusable
|
||||
│ │ ├── AdminThemeProvider.tsx
|
||||
│ │ ├── DarkModeToggle.tsx
|
||||
│ │ ├── UnifiedSurface.tsx
|
||||
│ │ └── UnifiedTypography.tsx
|
||||
│ │
|
||||
│ ├── con/ # Constants & konfigurasi
|
||||
│ │ └── colors.ts # Palet warna
|
||||
│ │
|
||||
│ ├── lib/ # Utility functions
|
||||
│ │ ├── router/ # Router utilities
|
||||
│ │ ├── api-auth.ts # Autentikasi API
|
||||
│ │ ├── api-fetch.ts # Helper fetch API
|
||||
│ │ ├── EnvStringParse.ts # Parser environment variables
|
||||
│ │ ├── prisma.ts # Prisma client instance
|
||||
│ │ ├── seafile-auth-service.ts # Integrasi Seafile
|
||||
│ │ └── session.ts # iron-session helper
|
||||
│ │
|
||||
│ ├── middlewares/ # Next.js middleware
|
||||
│ ├── state/ # Global state (Jotai/Valtio)
|
||||
│ │ ├── darkModeStore.ts # State dark mode
|
||||
│ │ ├── state-layanan.ts # State layanan
|
||||
│ │ ├── state-list-image.ts # State daftar gambar
|
||||
│ │ └── state-nav.ts # State navigasi
|
||||
│ │
|
||||
│ ├── store/ # State management tambahan
|
||||
│ └── types/ # TypeScript type definitions
|
||||
│
|
||||
├── public/ # Static assets
|
||||
│ └── assets/ # Gambar, icon, dll
|
||||
│
|
||||
├── uploads/ # Directory upload (runtime)
|
||||
│ └── image/ # Upload gambar
|
||||
│
|
||||
├── .env.example # Contoh environment variables
|
||||
├── .gitignore
|
||||
├── AGENTS.md # Panduan untuk AI coding agents
|
||||
├── Dockerfile # Docker image definition
|
||||
├── docker-entrypoint.sh # Entry point container
|
||||
├── next.config.ts # Next.js configuration
|
||||
├── package.json # Dependencies & scripts
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── biome.json # Biome linter config
|
||||
├── eslint.config.mjs # ESLint config
|
||||
├── NOTE.md # Catatan deployment
|
||||
└── QWEN.md # Konteks & memori proyek
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Arsitektur Aplikasi
|
||||
|
||||
### 3.1 Arsitektur Keseluruhan
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Client (Browser) │
|
||||
└────────────┬────────────────────────────┬────────────────┘
|
||||
│ │
|
||||
│ Next.js Pages │ API Calls
|
||||
│ (SSR/CSR) │
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────────┐
|
||||
│ Next.js 15 App Router│ │ Elysia.js API Server │
|
||||
│ - Pages publik │ │ - RESTful endpoints │
|
||||
│ - Admin dashboard │ │ - File upload │
|
||||
│ - Server components │ │ - Swagger docs (/api/docs│
|
||||
│ - Client components │ │ - Static file serving │
|
||||
└────────────┬───────────┘ └────────────┬───────────────┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL Database │
|
||||
│ (via Prisma ORM) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Seafile File Storage │
|
||||
│ (Images & Documents) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 Next.js App Router
|
||||
|
||||
- Menggunakan **App Router** (bukan Pages Router)
|
||||
- Route groups `(dashboard)`, `(pages)`, `(tambahan)` untuk organisasi tanpa mempengaruhi URL
|
||||
- Layout bersarang: root layout -> admin/darmasaba layout -> page layouts
|
||||
- `force-dynamic` digunakan untuk menghindari error prerendering
|
||||
- View Transitions API diaktifkan via `next-view-transitions`
|
||||
|
||||
### 3.3 Elysia.js API Server
|
||||
|
||||
- Terintegrasi sebagai **catch-all route** di `/api/[[...slugs]]/route.ts`
|
||||
- Semua HTTP methods (GET, POST, PATCH, DELETE, PUT) di-handle oleh Elysia
|
||||
- Plugin yang digunakan:
|
||||
- `@elysiajs/cors` - CORS configuration
|
||||
- `@elysiajs/static` - Static file serving dari `/uploads`
|
||||
- `@elysiajs/swagger` - API documentation di `/api/docs`
|
||||
- `@elysiajs/jwt` - JWT authentication
|
||||
- `@elysiajs/cookie` - Cookie handling
|
||||
- Endpoint file upload: `/api/upl-img`, `/api/upl-img-single`, `/api/upl-csv`
|
||||
- Image serving: `/api/img/:name` dengan resize support
|
||||
|
||||
### 3.4 Rendering Strategy
|
||||
|
||||
- **Server Components**: Halaman publik untuk SEO optimal
|
||||
- **Client Components**: Komponen interaktif (form, state, animasi)
|
||||
- **Force Dynamic**: Beberapa halaman menggunakan `force-dynamic`
|
||||
- **ISR**: Caching header untuk assets (1 jam cache)
|
||||
|
||||
---
|
||||
|
||||
## 4. Modul Domain
|
||||
|
||||
### 4.1 Profil Desa (Desa)
|
||||
**Admin**: `/admin/desa/*` | **Publik**: `/darmasaba/desa/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `berita` | CRUD berita/pengumuman desa |
|
||||
| `gallery` | Galeri foto dan video |
|
||||
| `layanan` | Manajemen layanan desa |
|
||||
| `penghargaan` | Penghargaan yang diraih |
|
||||
| `pengumuman` | Pengumuman publik |
|
||||
| `potensi` | Potensi desa (pertanian, pariwisata, dll) |
|
||||
| `profil` | Profil desa (sejarah, visi misi, lambang, maskot, perangkat) |
|
||||
|
||||
### 4.2 PPID (Pejabat Pengelola Informasi dan Dokumentasi)
|
||||
**Admin**: `/admin/ppid/*` | **Publik**: `/darmasaba/ppid/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `profil-ppid` | Profil pejabat PPID |
|
||||
| `struktur-ppid` | Struktur organisasi PPID |
|
||||
| `visi-misi-ppid` | Visi dan misi PPID |
|
||||
| `daftar-informasi-publik` | Daftar informasi yang tersedia |
|
||||
| `dasar-hukum` | Dasar hukum PPID |
|
||||
| `permohonan-informasi-publik` | Form permohonan informasi |
|
||||
| `permohonan-keberatan-informasi-publik` | Form keberatan |
|
||||
| `indeks-kepuasan-masyarakat` | Survei kepuasan masyarakat (IKM) |
|
||||
|
||||
### 4.3 Kesehatan
|
||||
**Admin**: `/admin/kesehatan/*` | **Publik**: `/darmasaba/kesehatan/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `fasilitas-kesehatan` | Data puskesmas, klinik, dokter |
|
||||
| `posyandu` | Manajemen posyandu |
|
||||
| `program-kesehatan` | Program kesehatan desa |
|
||||
| `info-wabah-penyakit` | Informasi wabah |
|
||||
| `penanganan-darurat` | Prosedur penanganan darurat |
|
||||
| `kontak-darurat` | Kontak darurat kesehatan |
|
||||
| `data-kesehatan-warga` | Statistik kesehatan warga |
|
||||
| `artikel-kesehatan` | Artikel kesehatan |
|
||||
|
||||
### 4.4 Ekonomi
|
||||
**Admin**: `/admin/ekonomi/*` | **Publik**: `/darmasaba/ekonomi/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `APBDes` | Anggaran Pendapatan dan Belanja Desa (hierarki items + realisasi) |
|
||||
| `PADesa-pendapatan-asli-desa` | Pendapatan asli desa |
|
||||
| `demografi-pekerjaan` | Demografi pekerjaan penduduk |
|
||||
| `jumlah-penduduk-miskin` | Data penduduk miskin |
|
||||
| `jumlah-pengangguran` | Data pengangguran |
|
||||
| `lowongan-kerja-lokal` | Lowongan kerja lokal |
|
||||
| `pasar-desa` | Data pasar desa |
|
||||
| `program-kemiskinan` | Program penanganan kemiskinan |
|
||||
| `sektor-unggulan-desa` | Sektor unggulan ekonomi |
|
||||
| `Struktur-Organisasi-Dan-Sk-Pengurus-BumDes` | Struktur BUMDes |
|
||||
|
||||
### 4.5 Kependudukan
|
||||
**Admin**: `/admin/kependudukan/*` | **Publik**: `/darmasaba/kependudukan/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `data-banjar` | Data banjar (unit wilayah tradisional Bali) |
|
||||
| `distribusi-agama` | Distribusi agama penduduk |
|
||||
| `distribusi-umur` | Distribusi umur penduduk |
|
||||
| `migrasi-penduduk` | Data migrasi (masuk/keluar) |
|
||||
|
||||
### 4.6 Pendidikan
|
||||
**Admin**: `/admin/pendidikan/*` | **Publik**: `/darmasaba/pendidikan/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `beasiswa-desa` | Program beasiswa |
|
||||
| `bimbingan-belajar-desa` | Bimbingan belajar |
|
||||
| `data-pendidikan` | Data statistik pendidikan |
|
||||
| `info-sekolah` | Informasi sekolah |
|
||||
| `pendidikan-non-formal` | Pendidikan non-formal |
|
||||
| `perpustakaan-digital` | Perpustakaan digital |
|
||||
| `program-pendidikan-anak` | Program pendidikan anak |
|
||||
|
||||
### 4.7 Keamanan
|
||||
**Admin**: `/admin/keamanan/*` | **Publik**: `/darmasaba/keamanan/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `keamanan-lingkungan-pecalang-patwal` | Keamanan lingkungan (pecalang Bali) |
|
||||
| `kontak-darurat` | Kontak darurat keamanan |
|
||||
| `laporan-publik` | Laporan publik |
|
||||
| `pencegahan-kriminalitas` | Pencegahan kriminalitas |
|
||||
| `polsek-terdekat` | Data polsek terdekat |
|
||||
| `tips-keamanan` | Tips keamanan |
|
||||
|
||||
### 4.8 Lingkungan
|
||||
**Admin**: `/admin/lingkungan/*` | **Publik**: `/darmasaba/lingkungan/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `data-lingkungan-desa` | Data lingkungan desa |
|
||||
| `edukasi-lingkungan` | Edukasi lingkungan |
|
||||
| `gotong-royong` | Kegiatan gotong royong |
|
||||
| `konservasi-adat-bali` | Konservasi adat Bali |
|
||||
| `pengelolaan-sampah-bank-sampah` | Bank sampah |
|
||||
| `program-penghijauan` | Program penghijauan |
|
||||
|
||||
### 4.9 Inovasi
|
||||
**Admin**: `/admin/inovasi/*` | **Publik**: `/darmasaba/inovasi/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `ajukan-ide-inovatif` | Form pengajuan ide inovatif |
|
||||
| `desa-digital-smart-village` | Program desa digital |
|
||||
| `info-teknologi-tepat-guna` | Info teknologi tepat guna |
|
||||
| `kolaborasi-inovasi` | Kolaborasi inovasi |
|
||||
| `layanan-online-desa` | Layanan online desa |
|
||||
| `program-kreatif-desa` | Program kreatif desa |
|
||||
|
||||
### 4.10 Musik Desa
|
||||
**Admin**: `/admin/musik/*` | **Publik**: `/darmasaba/musik/*`
|
||||
|
||||
- Manajemen audio dan cover musik desa
|
||||
- Fixed player bar di halaman publik
|
||||
- Context provider untuk state pemutar musik
|
||||
|
||||
### 4.11 Landing Page
|
||||
**Admin**: `/admin/landing-page/*`
|
||||
|
||||
| Sub-modul | Fungsi |
|
||||
|-----------|--------|
|
||||
| `desa-anti-korupsi` | Konten anti-korupsi |
|
||||
| `prestasi-desa` | Prestasi yang diraih |
|
||||
| `sdgs-desa` | SDGs (Sustainable Development Goals) |
|
||||
| `profil-landing-page` | Profil dan media sosial |
|
||||
|
||||
### 4.12 User & Role
|
||||
**Admin**: `/admin/user&role/*`
|
||||
|
||||
- Manajemen pengguna admin
|
||||
- Manajemen role dan permission
|
||||
- Manajemen menu akses
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Schema
|
||||
|
||||
### 5.1 Overview
|
||||
|
||||
Database menggunakan **PostgreSQL** dengan **Prisma ORM** (versi 6.3.1).
|
||||
Schema terdiri dari **2413 baris** dengan **100+ model**.
|
||||
|
||||
### 5.2 Model Utama
|
||||
|
||||
#### FileStorage
|
||||
Model sentral untuk semua file (gambar, dokumen, audio):
|
||||
```prisma
|
||||
model FileStorage {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
realName String
|
||||
path String
|
||||
mimeType String
|
||||
category String // "image" / "document" / "audio" / "other"
|
||||
link String
|
||||
isActive Boolean @default(true)
|
||||
// Relasi ke 50+ model lain (Berita, PotensiDesa, GalleryFoto, dll)
|
||||
}
|
||||
```
|
||||
|
||||
#### AppMenu & AppMenuChild
|
||||
Menu navigasi aplikasi:
|
||||
```prisma
|
||||
model AppMenu {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
link String
|
||||
isActive Boolean @default(true)
|
||||
AppMenuChild AppMenuChild[]
|
||||
}
|
||||
```
|
||||
|
||||
#### User & Role (Autentikasi Admin)
|
||||
- `User` - Data pengguna admin
|
||||
- `Role` - Role/peran pengguna
|
||||
- `Menu` - Menu akses per role
|
||||
|
||||
#### Modul Desa
|
||||
- `Berita` - Berita desa (dengan featured image & gallery)
|
||||
- `GalleryFoto` / `GalleryVideo` - Galeri media
|
||||
- `Layanan` - Layanan desa
|
||||
- `Pengumuman` - Pengumuman
|
||||
- `PotensiDesa` - Potensi desa
|
||||
- `ProfileDesaImage` - Gambar profil desa
|
||||
- `ProfilPerbekel` - Profil perbekel (kepala desa)
|
||||
- `PejabatDesa` - Pejabat desa
|
||||
- `Penghargaan` - Penghargaan
|
||||
- `PrestasiDesa` - Prestasi
|
||||
- `MediaSosial` - Media sosial desa
|
||||
|
||||
#### Modul PPID
|
||||
- `StrukturPPID` - Struktur organisasi
|
||||
- `PosisiOrganisasiPPID` - Posisi dengan hierarki
|
||||
- `PegawaiPPID` - Data pegawai
|
||||
- `ProfilePPID` - Profil PPID
|
||||
- `VisiMisiPPID` - Visi misi
|
||||
- `DasarHukumPPID` - Dasar hukum
|
||||
- `DaftarInformasiPublik` - Daftar informasi
|
||||
- `PermohonanInformasiPublik` - Permohonan informasi
|
||||
- `FormulirPermohonanKeberatan` - Formulir keberatan
|
||||
- `IndeksKepuasanMasyarakat` - IKM
|
||||
- `Responden` + lookup tables - Data responden IKM
|
||||
|
||||
#### Modul Kesehatan
|
||||
- `Puskesmas` - Data puskesmas
|
||||
- `Posyandu` - Data posyandu
|
||||
- `ProgramKesehatan` - Program kesehatan
|
||||
- `FasilitasKesehatan` - Fasilitas
|
||||
- `InfoWabahPenyakit` - Info wabah
|
||||
- `PenangananDarurat` - Penanganan darurat
|
||||
- `KontakDarurat` - Kontak darurat
|
||||
- `ArtikelKesehatan` - Artikel
|
||||
|
||||
#### Modul Ekonomi
|
||||
- `APBDes` & `APBDesItem` - Anggaran desa (hierarki tree structure)
|
||||
- `RealisasiItem` - Realisasi anggaran (multiple per item)
|
||||
- `PasarDesa` - Pasar desa
|
||||
- `PegawaiBumDes` - Pegawai BUMDes
|
||||
- `StrukturBumDes` - Struktur BUMDes
|
||||
- `DemografiPekerjaan` - Demografi pekerjaan
|
||||
- `JumlahPendudukMiskin` - Data kemiskinan
|
||||
- `JumlahPengangguran` - Data pengangguran
|
||||
- `LowonganKerjaLokal` - Lowongan kerja
|
||||
- `ProgramKemiskinan` - Program kemiskinan
|
||||
- `SektorUnggulanDesa` - Sektor unggulan
|
||||
- `PendapatanAsli` - Pendapatan asli desa
|
||||
|
||||
#### Modul Kependudukan
|
||||
- `DataBanjar` - Data banjar
|
||||
- `DistribusiAgama` - Distribusi agama
|
||||
- `DistribusiUmur` - Distribusi umur
|
||||
- `MigrasiPenduduk` - Migrasi
|
||||
|
||||
#### Modul Pendidikan
|
||||
- `InfoSekolah` - Data sekolah
|
||||
- `BeasiswaDesa` - Beasiswa
|
||||
- `BimbinganBelajar` - Bimbingan belajar
|
||||
- `PendidikanNonFormal` - Pendidikan non-formal
|
||||
- `DataPerpustakaan` - Perpustakaan
|
||||
|
||||
#### Modul Keamanan
|
||||
- `KeamananLingkungan` - Keamanan lingkungan
|
||||
- `MenuTipsKeamanan` - Tips keamanan
|
||||
- `PencegahanKriminalitas` - Pencegahan kriminalitas
|
||||
- `PolsekTerdekat` - Polsek terdekat
|
||||
- `LaporanPublik` - Laporan publik
|
||||
|
||||
#### Modul Lingkungan
|
||||
- `DataLingkunganDesa` - Data lingkungan
|
||||
- `KonservasiAdatBali` - Konservasi adat
|
||||
- `BankSampah` - Bank sampah
|
||||
- `ProgramPenghijauan` - Penghijauan
|
||||
- `GotongRoyong` - Gotong royong
|
||||
- `EdukasiLingkungan` - Edukasi
|
||||
|
||||
#### Modul Inovasi
|
||||
- `ProgramInovasi` - Program inovasi
|
||||
- `DesaDigital` - Desa digital
|
||||
- `InfoTekno` - Info teknologi
|
||||
- `KolaborasiInovasi` + `MitraKolaborasi` - Kolaborasi
|
||||
- `LayananOnlineDesa` - Layanan online
|
||||
- `ProgramKreatifDesa` - Program kreatif
|
||||
- `Ajukan` - Pengajuan ide
|
||||
|
||||
#### Modul Musik
|
||||
- `MusikDesa` - Musik desa
|
||||
- `audioFile` -> FileStorage
|
||||
- `coverImage` -> FileStorage
|
||||
|
||||
#### Landing Page
|
||||
- `DesaAntiKorupsi` + `KategoriDesaAntiKorupsi`
|
||||
- `SdgsDesa` - SDGs
|
||||
- `PrestasiDesa` + `KategoriPrestasiDesa`
|
||||
- `MediaSosial`
|
||||
- `LandingPage_Layanan`
|
||||
|
||||
#### APBDes (Struktur Hierarki)
|
||||
```prisma
|
||||
model APBDesItem {
|
||||
kode String // "4", "4.1", "4.1.2"
|
||||
uraian String // Nama item
|
||||
anggaran Float // Anggaran dalam Rupiah
|
||||
tipe String? // "pendapatan" | "belanja" | "pembiayaan"
|
||||
level Int // 1, 2, 3
|
||||
parentId String? // Self-referencing untuk tree
|
||||
children APBDesItem[]
|
||||
totalRealisasi Float @default(0) // Auto-calculated
|
||||
selisih Float @default(0) // totalRealisasi - anggaran
|
||||
persentase Float @default(0) // (totalRealisasi / anggaran) * 100
|
||||
realisasiItems RealisasiItem[]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Pola Umum Model
|
||||
|
||||
Hampir semua model mengikuti pola:
|
||||
```prisma
|
||||
model Contoh {
|
||||
id String @id @default(cuid())
|
||||
// ... fields
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime? @default(now()) // Soft delete
|
||||
isActive Boolean @default(true) // Soft delete flag
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API Routes
|
||||
|
||||
### 6.1 Struktur API
|
||||
|
||||
Semua API routes ditangani oleh **Elysia.js** di `/src/app/api/[[...slugs]]/route.ts`
|
||||
|
||||
### 6.2 API Groups
|
||||
|
||||
| Prefix | Modul | Contoh Endpoints |
|
||||
|--------|-------|------------------|
|
||||
| `/api/layanan` | Layanan | `GET /api/layanan` |
|
||||
| `/api/potensi` | Potensi | `GET /api/potensi` |
|
||||
| `/api/desa/*` | Desa | CRUD berita, gallery, profil, dll |
|
||||
| `/api/ppid/*` | PPID | CRUD struktur, profil, permohonan |
|
||||
| `/api/kesehatan/*` | Kesehatan | CRUD puskesmas, posyandu, dll |
|
||||
| `/api/ekonomi/*` | Ekonomi | CRUD APBDes, BUMDes, demografi |
|
||||
| `/api/kependudukan/*` | Kependudukan | CRUD banjar, demografi |
|
||||
| `/api/pendidikan/*` | Pendidikan | CRUD sekolah, beasiswa |
|
||||
| `/api/keamanan/*` | Keamanan | CRUD keamanan, kontak darurat |
|
||||
| `/api/lingkungan/*` | Lingkungan | CRUD data lingkungan |
|
||||
| `/api/inovasi/*` | Inovasi | CRUD program inovasi |
|
||||
| `/api/landing-page/*` | Landing Page | CRUD konten landing page |
|
||||
| `/api/user/*` | User | CRUD user admin |
|
||||
| `/api/user/role/*` | Role | CRUD role & permission |
|
||||
| `/api/search` | Search | Pencarian global |
|
||||
| `/api/file-storage/*` | File Storage | CRUD file storage |
|
||||
| `/api/img/:name` | Image | GET gambar dengan resize |
|
||||
| `/api/upl-img` | Upload | Upload multiple images |
|
||||
| `/api/upl-img-single` | Upload | Upload single image |
|
||||
| `/api/upl-csv` | Upload | Upload CSV files |
|
||||
| `/api/utils/version` | Utils | GET versi aplikasi |
|
||||
|
||||
### 6.3 API Documentation
|
||||
|
||||
Swagger UI tersedia di: **`/api/docs`**
|
||||
|
||||
### 6.4 API Route Lainnya
|
||||
|
||||
| Route | Fungsi |
|
||||
|-------|--------|
|
||||
| `/api/health` | Health check endpoint |
|
||||
| `/api/news` | API berita (standalone) |
|
||||
| `/api/subscribe` | Subscription email |
|
||||
| `/api/tts` | Text-to-Speech (ElevenLabs) |
|
||||
| `/api/admin/*` | API khusus admin |
|
||||
| `/api/auth/*` | API autentikasi |
|
||||
|
||||
---
|
||||
|
||||
## 7. Halaman Admin
|
||||
|
||||
### 7.1 Struktur
|
||||
|
||||
Admin dashboard berada di `/admin` dengan route group `(dashboard)`.
|
||||
|
||||
| Section | Path | Fungsi |
|
||||
|---------|------|--------|
|
||||
| **Dashboard** | `/admin` | Dashboard utama |
|
||||
| **Autentikasi** | `/admin/auth` | Login admin |
|
||||
| **Desa** | `/admin/desa/*` | Berita, gallery, profil, layanan, penghargaan, pengumuman, potensi |
|
||||
| **PPID** | `/admin/ppid/*` | Profil, struktur, visi-misi, daftar informasi, dasar hukum, permohonan, IKM |
|
||||
| **Kesehatan** | `/admin/kesehatan/*` | Puskesmas, posyandu, program kesehatan, wabah, kontak darurat |
|
||||
| **Ekonomi** | `/admin/ekonomi/*` | APBDes, PAD, demografi, pengangguran, kemiskinan, BUMDes, pasar desa |
|
||||
| **Kependudukan** | `/admin/kependudukan/*` | Banjar, distribusi agama, distribusi umur, migrasi |
|
||||
| **Pendidikan** | `/admin/pendidikan/*` | Sekolah, beasiswa, bimbingan belajar, perpustakaan digital |
|
||||
| **Keamanan** | `/admin/keamanan/*` | Keamanan lingkungan, kontak darurat, pencegahan kriminalitas, polsek |
|
||||
| **Lingkungan** | `/admin/lingkungan/*` | Data lingkungan, konservasi, bank sampah, penghijauan, gotong royong |
|
||||
| **Inovasi** | `/admin/inovasi/*` | Ide inovatif, desa digital, teknologi tepat guna, kolaborasi |
|
||||
| **Musik** | `/admin/musik/*` | Manajemen musik desa |
|
||||
| **Landing Page** | `/admin/landing-page/*` | Anti-korupsi, prestasi, SDGs, media sosial |
|
||||
| **User & Role** | `/admin/user&role/*` | Manajemen user dan role |
|
||||
| **Images** | `/admin/images/*` | Manajemen gambar |
|
||||
| **CSV** | `/admin/csv/*` | Upload/import CSV |
|
||||
|
||||
### 7.2 Komponen Admin Shared
|
||||
|
||||
- `AdminThemeProvider.tsx` - Theme provider untuk dark/light mode
|
||||
- `DarkModeToggle.tsx` - Toggle dark mode
|
||||
- `UnifiedSurface.tsx` - Komponen surface/card unified
|
||||
- `UnifiedTypography.tsx` - Tipografi unified
|
||||
|
||||
---
|
||||
|
||||
## 8. Halaman Publik
|
||||
|
||||
### 8.1 Struktur
|
||||
|
||||
Halaman publik berada di `/darmasaba` dengan layout yang mencakup Navbar, Footer, dan Fixed Music Player.
|
||||
|
||||
| Halaman | Path | Konten |
|
||||
|---------|------|--------|
|
||||
| **Landing Page
|
||||
842
STRUKTUR.md
Normal file
842
STRUKTUR.md
Normal file
@@ -0,0 +1,842 @@
|
||||
# Dokumentasi Struktur Proyek Desa Darmasaba
|
||||
|
||||
## 1. Ringkasan Proyek
|
||||
|
||||
**Desa Darmasaba** adalah aplikasi web manajemen desa digital untuk Desa Darmasaba, Kabupaten Badung, Bali. Aplikasi ini berfungsi sebagai platform layanan publik digital yang mencakup informasi pemerintahan, layanan kesehatan, keamanan, pendidikan, ekonomi, lingkungan, dan inovasi desa.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Kategori | Teknologi |
|
||||
|----------|-----------|
|
||||
| **Framework** | Next.js 15 (App Router) |
|
||||
| **Language** | TypeScript (strict mode) |
|
||||
| **Runtime** | Bun |
|
||||
| **Backend API** | Elysia.js (high-performance HTTP server) |
|
||||
| **Database** | PostgreSQL |
|
||||
| **ORM** | Prisma 6.3.1 |
|
||||
| **UI Framework** | Mantine UI v7-v8 |
|
||||
| **State Management** | Jotai + Valtio + SWR |
|
||||
| **Authentication** | iron-session + JWT (@elysiajs/jwt) |
|
||||
| **File Storage** | Seafile (self-hosted) |
|
||||
| **Text Editor** | Tiptap (Rich text editor) |
|
||||
| **Charts** | Recharts + Chart.js |
|
||||
| **Maps** | Leaflet + react-leaflet |
|
||||
| **Testing** | Vitest (unit) + Playwright (E2E) |
|
||||
| **Styling** | Mantine + PostCSS + Framer Motion |
|
||||
| **Deployment** | Docker + GHCR + Portainer + GitHub Actions |
|
||||
| **Version** | 0.1.11 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Struktur Direktori
|
||||
|
||||
```
|
||||
desa-darmasaba/
|
||||
├── .github/workflows/ # GitHub Actions CI/CD
|
||||
│ ├── docker-publish.yml # Auto build & push saat tag v*
|
||||
│ ├── publish.yml # Manual build & push ke GHCR
|
||||
│ ├── re-pull.yml # Manual re-pull di Portainer
|
||||
│ └── script/ # Shell scripts untuk deploy
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Database schema (2413 baris, 100+ model)
|
||||
│ └── seed.ts # Database seeder (400+ baris)
|
||||
│ └── _seeder_list/ # Seed data per modul
|
||||
├── public/ # Static assets
|
||||
│ └── assets/
|
||||
│ └── images/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── _com/ # Global components (SplashScreen, WebVitals)
|
||||
│ │ ├── admin/ # ADMIN DASHBOARD
|
||||
│ │ │ ├── (dashboard)/ # Route group dashboard
|
||||
│ │ │ │ ├── desa/ # - Berita, Gallery, Layanan, dll
|
||||
│ │ │ │ ├── ppid/ # - Informasi publik, struktur, dasar hukum
|
||||
│ │ │ │ ├── kesehatan/ # - Fasilitas, posyandu, puskesmas, wabah
|
||||
│ │ │ │ ├── ekonomi/ # - APBDes, pasar desa, BUMDes, dll
|
||||
│ │ │ │ ├── kependudukan/ # - Banjar, agama, umur, migrasi
|
||||
│ │ │ │ ├── pendidikan/ # - Sekolah, beasiswa, perpustakaan
|
||||
│ │ │ │ ├── keamanan/ # - Keamanan lingkungan, polsek, dll
|
||||
│ │ │ │ ├── lingkungan/ # - Sampah, penghijauan, gotong royong
|
||||
│ │ │ │ ├── inovasi/ # - Desa digital, kolaborasi, dll
|
||||
│ │ │ │ ├── landing-page/ # - Profil, prestasi, anti-korupsi
|
||||
│ │ │ │ ├── musik/ # - Musik desa
|
||||
│ │ │ │ ├── user&role/ # - Manajemen user & role
|
||||
│ │ │ │ └── _com/ # - Shared admin components
|
||||
│ │ │ ├── auth/ # Login OTP untuk admin
|
||||
│ │ │ ├── csv/ # Demo CSV upload
|
||||
│ │ │ └── layout.tsx # Admin shell (AppShell Mantine)
|
||||
│ │ ├── api/ # ELYSIA.JS API SERVER
|
||||
│ │ │ ├── [[...slugs]]/ # Catch-all route -> Elysia handler
|
||||
│ │ │ │ ├── route.ts # - Main Elysia server export
|
||||
│ │ │ │ └── _lib/ # - Domain route modules
|
||||
│ │ │ │ ├── desa.ts
|
||||
│ │ │ │ ├── ppid.ts
|
||||
│ │ │ │ ├── kesehatan.ts
|
||||
│ │ │ │ ├── ekonomi.ts
|
||||
│ │ │ │ ├── keamanan.ts
|
||||
│ │ │ │ ├── inovasi.ts
|
||||
│ │ │ │ ├── lingkungan.ts
|
||||
│ │ │ │ ├── pendidikan.ts
|
||||
│ │ │ │ ├── kependudukan.ts
|
||||
│ │ │ │ ├── landing_page.ts
|
||||
│ │ │ │ ├── user/ # - User & Role management
|
||||
│ │ │ │ ├── fileStorage/
|
||||
│ │ │ │ ├── search/
|
||||
│ │ │ │ ├── auth/
|
||||
│ │ │ │ ├── upl-img.ts, upl-img-single.ts
|
||||
│ │ │ │ ├── upl-csv.ts, upl-csv-single.ts
|
||||
│ │ │ │ └── img.ts, img-del.ts, imgs.ts
|
||||
│ │ │ ├── auth/ # Auth endpoints (login, logout, me)
|
||||
│ │ │ └── ... # Other API routes
|
||||
│ │ ├── darmasaba/ # PUBLIC-FACING WEBSITE
|
||||
│ │ │ ├── _com/ # Shared components (Navbar, Footer, etc)
|
||||
│ │ │ ├── (pages)/ # Public pages route group
|
||||
│ │ │ │ ├── desa/ # - Profil, berita, gallery, layanan
|
||||
│ │ │ │ ├── ppid/ # - PPID public pages
|
||||
│ │ │ │ ├── kesehatan/ # - Health info pages
|
||||
│ │ │ │ ├── ekonomi/ # - Economy pages
|
||||
│ │ │ │ ├── kependudukan/
|
||||
│ │ │ │ ├── pendidikan/
|
||||
│ │ │ │ ├── keamanan/
|
||||
│ │ │ │ ├── lingkungan/
|
||||
│ │ │ │ ├── inovasi/
|
||||
│ │ │ │ ├── musik/
|
||||
│ │ │ │ └── module/ # - External module links
|
||||
│ │ │ └── (tambahan)/ # Additional pages
|
||||
│ │ ├── login/ # Login page
|
||||
│ │ ├── registrasi/ # Registration page
|
||||
│ │ ├── waiting-room/ # Waiting room (inactive users)
|
||||
│ │ ├── terms-of-service/
|
||||
│ │ ├── layout.tsx # Root layout (MantineProvider, ViewTransitions)
|
||||
│ │ └── page.tsx # Homepage redirect
|
||||
│ ├── components/
|
||||
│ │ └── admin/ # Admin shared components
|
||||
│ │ ├── AdminThemeProvider.tsx
|
||||
│ │ ├── DarkModeToggle.tsx
|
||||
│ │ ├── UnifiedSurface.tsx
|
||||
│ │ └── UnifiedTypography.tsx
|
||||
│ ├── con/ # Constants & configuration
|
||||
│ │ ├── colors.ts # Color palette definitions
|
||||
│ │ ├── images.ts
|
||||
│ │ ├── navbar-list-menu.ts
|
||||
│ │ ├── router.ts # Route mapping
|
||||
│ │ └── sosmed.ts
|
||||
│ ├── context/ # React contexts
|
||||
│ │ └── MusicContext.tsx # Music player context
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── lib/ # Utility libraries
|
||||
│ │ ├── router/
|
||||
│ │ ├── api-auth.ts # API authentication helpers
|
||||
│ │ ├── api-fetch.ts # API fetch wrapper
|
||||
│ │ ├── EnvStringParse.ts
|
||||
│ │ ├── prisma.ts # Prisma client singleton
|
||||
│ │ ├── seafile-auth-service.ts
|
||||
│ │ └── session.ts # iron-session helper
|
||||
│ ├── state/ # Global state (Jotai/Valtio)
|
||||
│ │ ├── darkModeStore.ts
|
||||
│ │ ├── state-layanan.ts
|
||||
│ │ ├── state-list-image.ts
|
||||
│ │ └── state-nav.ts
|
||||
│ ├── store/ # Additional stores
|
||||
│ │ └── authStore.ts # Auth state (Jotai)
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ └── utils/ # Utility functions
|
||||
│ └── themeTokens.ts # Dark/light theme tokens
|
||||
├── uploads/ # Local upload directory (images/files)
|
||||
├── Dockerfile # Multi-stage Docker build (Bun)
|
||||
├── docker-entrypoint.sh # Entry script (migrate + start)
|
||||
├── next.config.ts # Next.js configuration
|
||||
├── package.json # Dependencies & scripts
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── biome.json # Biome linter config
|
||||
├── eslint.config.mjs # ESLint config
|
||||
├── NOTE.md # Deployment notes
|
||||
├── QWEN.md # Project memory & workflow
|
||||
└── AGENTS.md # Agent coding guidelines
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Arsitektur
|
||||
|
||||
### Pola Arsitektur: Full-Stack Monolith dengan App Router
|
||||
|
||||
```
|
||||
Browser
|
||||
|
|
||||
+-- Next.js 15 (App Router) -- Server Components + Client Components
|
||||
|
|
||||
+-- /darmasaba/* -> Public pages (SSR/CSR)
|
||||
+-- /admin/* -> Admin dashboard (protected)
|
||||
+-- /api/* -> Elysia.js API server
|
||||
|
|
||||
+-- Elysia Server (src/app/api/[[...slugs]]/route.ts)
|
||||
|
|
||||
+-- CORS enabled
|
||||
+-- Swagger docs di /api/docs
|
||||
+-- Static file serving (/api/uploads)
|
||||
+-- Domain modules: Desa, PPID, Kesehatan, Ekonomi, dll
|
||||
+-- Image upload handlers
|
||||
|
|
||||
+-- Prisma ORM --> PostgreSQL
|
||||
+-- Seafile API --> File Storage
|
||||
```
|
||||
|
||||
### Key Architectural Decisions:
|
||||
|
||||
1. **Next.js 15 App Router**: Menggunakan React Server Components sebagai default, dengan `"use client"` untuk interaktivitas
|
||||
2. **Elysia.js di dalam API Routes**: Catch-all route `[[...slugs]]` meneruskan semua request ke Elysia handler
|
||||
3. **Route Groups**: `(dashboard)` dan `(pages)` untuk organisasi tanpa mempengaruhi URL path
|
||||
4. **Multi-tenant Ready**: Role-based access control dengan dynamic navbar berdasarkan roleId
|
||||
5. **File Uploads**: Local uploads + Seafile integration untuk distributed storage
|
||||
|
||||
---
|
||||
|
||||
## 4. Modul Domain
|
||||
|
||||
### A. PPID (Pejabat Pengelola Informasi dan Dokumentasi)
|
||||
**Lokasi**: `src/app/admin/(dashboard)/ppid/` dan `src/app/darmasaba/(pages)/ppid/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Profil PPID | Profil pejabat pengelola informasi |
|
||||
| Struktur PPID | Struktur organisasi PPID dengan hierarki |
|
||||
| Visi & Misi PPID | Visi dan misi PPID desa |
|
||||
| Daftar Informasi Publik | Katalog informasi publik yang tersedia |
|
||||
| Dasar Hukum | Regulasi dan dasar hukum PPID |
|
||||
| Permohonan Informasi Publik | Form permohonan informasi (NIK, kontak, jenis) |
|
||||
| Permohonan Keberatan | Formulir keberatan informasi |
|
||||
| Indeks Kepuasan Masyarakat | Survey kepuasan dengan grafik demografis |
|
||||
|
||||
### B. Desa (Landing Page & Umum)
|
||||
**Lokasi**: `src/app/admin/(dashboard)/desa/` dan `src/app/darmasaba/(pages)/desa/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Profil Desa | Sejarah, visi-misi, lambang, maskot |
|
||||
| Profil Perbekel | Biodata, pengalaman, program unggulan perbekel |
|
||||
| Perbekel dari Masa ke Masa | Historis perbekel per periode |
|
||||
| Berita | Artikel berita dengan kategori & multi-image |
|
||||
| Gallery | Foto dan video galeri |
|
||||
| Pengumuman | Pengumuman desa dengan kategori |
|
||||
| Potensi Desa | Potensi desa dengan kategori |
|
||||
| Layanan Desa | Surat keterangan, ajukan permohonan |
|
||||
| Penghargaan | Prestasi dan penghargaan desa |
|
||||
| Desa Anti Korupsi | Transparansi anti-korupsi |
|
||||
| SDGs Desa | Sustainable Development Goals desa |
|
||||
| APBDes | Anggaran desa dengan hierarki item & realisasi |
|
||||
| Prestasi Desa | Katalog prestasi |
|
||||
|
||||
### C. Kesehatan
|
||||
**Lokasi**: `src/app/admin/(dashboard)/kesehatan/` dan `src/app/darmasaba/(pages)/kesehatan/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Fasilitas Kesehatan | Info rumah sakit/klinik (jam, dokter, tarif) |
|
||||
| Puskesmas | Data puskesmas dengan jam operasional & kontak |
|
||||
| Posyandu | Jadwal dan informasi posyandu |
|
||||
| Program Kesehatan | Program-program kesehatan desa |
|
||||
| Penanganan Darurat | Prosedur penanganan darurat |
|
||||
| Kontak Darurat | Kontak emergency dengan WhatsApp |
|
||||
| Info Wabah Penyakit | Informasi wabah penyakit |
|
||||
| Artikel Kesehatan | Artikel kesehatan lengkap |
|
||||
| Data Kesehatan Warga | Statistik kesehatan warga |
|
||||
| Kelahiran & Kematian | Data vital statistik |
|
||||
| Grafik Kepuasan | Grafik kepuasan layanan kesehatan |
|
||||
|
||||
### D. Ekonomi
|
||||
**Lokasi**: `src/app/admin/(dashboard)/ekonomi/` dan `src/app/darmasaba/(pages)/ekonomi/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Pasar Desa | Katalog pasar desa dengan produk & rating |
|
||||
| Struktur BUMDes | Organisasi BUMDes dengan pengurus |
|
||||
| APBDes (PADesa) | Pendapatan Asli Desa |
|
||||
| Program Kemiskinan | Program dan statistik kemiskinan |
|
||||
| Sektor Unggulan | Sektor ekonomi unggulan desa |
|
||||
| Lowongan Kerja Lokal | Info lowongan pekerjaan |
|
||||
| Demografi Pekerjaan | Distribusi pekerjaan penduduk |
|
||||
| Jumlah Pengangguran | Statistik pengangguran |
|
||||
| Penduduk Usia Kerja Menganggur | Analisis pengangguran by usia & pendidikan |
|
||||
| Jumlah Penduduk Miskin | Tren kemiskinan tahunan |
|
||||
|
||||
### E. Kependudukan
|
||||
**Lokasi**: `src/app/admin/(dashboard)/kependudukan/` dan `src/app/darmasaba/(pages)/kependudukan/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Data Banjar | Data penduduk per banjar |
|
||||
| Distribusi Agama | Statistik agama penduduk |
|
||||
| Distribusi Umur | Piramida umur penduduk |
|
||||
| Migrasi Penduduk | Data migrasi masuk/keluar |
|
||||
| Dinamika Penduduk | Kelahiran, kematian, migrasi per tahun |
|
||||
|
||||
### F. Pendidikan
|
||||
**Lokasi**: `src/app/admin/(dashboard)/pendidikan/` dan `src/app/darmasaba/(pages)/pendidikan/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Info Sekolah & PAUD | Data sekolah per jenjang (TK, SD, SMP, SMA) |
|
||||
| Beasiswa Desa | Program beasiswa & pendaftar |
|
||||
| Program Pendidikan Anak | Program pendidikan anak |
|
||||
| Bimbingan Belajar | Informasi bimbingan belajar |
|
||||
| Pendidikan Non Formal | Tempat & program non-formal |
|
||||
| Perpustakaan Digital | Katalog buku & peminjaman |
|
||||
| Data Pendidikan | Statistik pendidikan |
|
||||
|
||||
### G. Keamanan
|
||||
**Lokasi**: `src/app/admin/(dashboard)/keamanan/` dan `src/app/darmasaba/(pages)/keamanan/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Keamanan Lingkungan (Pecalang/Patwal) | Sistem keamanan tradisional Bali |
|
||||
| Polsek Terdekat | Data polsek dengan layanan & map |
|
||||
| Kontak Darurat | Kontak darurat keamanan |
|
||||
| Pencegahan Kriminalitas | Info pencegahan kriminal |
|
||||
| Laporan Publik | Laporan masyarakat dengan tracking status |
|
||||
| Tips Keamanan | Tips dan panduan keamanan |
|
||||
|
||||
### H. Lingkungan
|
||||
**Lokasi**: `src/app/admin/(dashboard)/lingkungan/` dan `src/app/darmasaba/(pages)/lingkungan/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Pengelolaan Sampah | Bank sampah & pengelolaan |
|
||||
| Program Penghijauan | Program penghijauan desa |
|
||||
| Data Lingkungan | Data lingkungan desa |
|
||||
| Gotong Royong | Kegiatan gotong royong |
|
||||
| Edukasi Lingkungan | Edukasi lingkungan hidup |
|
||||
| Konservasi Adat Bali | Tri Hita Karana & konservasi adat |
|
||||
|
||||
### I. Inovasi
|
||||
**Lokasi**: `src/app/admin/(dashboard)/inovasi/` dan `src/app/darmasaba/(pages)/inovasi/`
|
||||
|
||||
| Sub-modul | Deskripsi |
|
||||
|-----------|-----------|
|
||||
| Desa Digital (Smart Village) | Transformasi digital desa |
|
||||
| Program Kreatif Desa | Program kreatif & inovatif |
|
||||
| Kolaborasi Inovasi | Kolaborasi dengan mitra |
|
||||
| Info Teknologi Tepat Guna | Info teknologi untuk desa |
|
||||
| Ajukan Ide Inovatif | Form pengajuan ide dari warga |
|
||||
| Layanan Online Desa | Layanan administrasi online |
|
||||
|
||||
### J. Musik Desa
|
||||
**Lokasi**: `src/app/admin/(dashboard)/musik/` dan `src/app/darmasaba/(pages)/musik/`
|
||||
|
||||
Model `MusikDesa` dengan audio file, cover image, genre, dan durasi. Dilengkapi dengan `FixedPlayerBar` di layout publik.
|
||||
|
||||
### K. User & Role (Admin)
|
||||
**Lokasi**: `src/app/admin/(dashboard)/user&role/`
|
||||
|
||||
- **Role-based Access Control**: Role dengan permission JSON
|
||||
- **User Session Management**: Multiple sessions per user dengan JWT
|
||||
- **OTP Authentication**: Login dengan nomor telepon + OTP
|
||||
- **Menu Access Control**: Dynamic navbar berdasarkan menu akses user
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Schema (Prisma)
|
||||
|
||||
Schema terdiri dari **2413 baris** dengan **100+ model** dan **berbagai enum**. Berikut model-model utama:
|
||||
|
||||
### Core Models
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `FileStorage` | Central file storage untuk semua uploaded files |
|
||||
| `AppMenu` / `AppMenuChild` | Menu navigasi aplikasi |
|
||||
| `User` / `Role` / `UserSession` / `UserMenuAccess` | Sistem autentikasi & otorisasi |
|
||||
| `KodeOtp` | OTP codes untuk login |
|
||||
|
||||
### Landing Page & Desa
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `PejabatDesa` | Pejabat desa dengan foto |
|
||||
| `ProfilPerbekel` | Profil perbekel (biodata, pengalaman, program) |
|
||||
| `PerbekelDariMasaKeMasa` | Historis perbekel |
|
||||
| `Berita` / `KategoriBerita` | Berita desa |
|
||||
| `PotensiDesa` / `KategoriPotensi` | Potensi desa |
|
||||
| `Pengumuman` / `CategoryPengumuman` | Pengumuman |
|
||||
| `GalleryFoto` / `GalleryVideo` | Gallery media |
|
||||
| `Penghargaan` | Penghargaan desa |
|
||||
| `APBDes` / `APBDesItem` / `RealisasiItem` | Anggaran dengan realisasi |
|
||||
| `DesaAntiKorupsi` / `KategoriDesaAntiKorupsi` | Transparansi |
|
||||
| `SdgsDesa` | SDGs desa |
|
||||
| `PrestasiDesa` / `KategoriPrestasiDesa` | Prestasi |
|
||||
| `MusikDesa` | Musik desa |
|
||||
|
||||
### PPID
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `StrukturPPID` / `PosisiOrganisasiPPID` / `PegawaiPPID` | Struktur organisasi |
|
||||
| `VisiMisiPPID` | Visi misi |
|
||||
| `ProfilePPID` | Profil pejabat |
|
||||
| `DasarHukumPPID` | Regulasi |
|
||||
| `DaftarInformasiPublik` | Katalog informasi |
|
||||
| `PermohonanInformasiPublik` | Permohonan + lookup tables |
|
||||
| `FormulirPermohonanKeberatan` | Keberatan |
|
||||
| `IndeksKepuasanMasyarakat` + grafik breakdown | Survey kepuasan |
|
||||
|
||||
### Kesehatan
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `FasilitasKesehatan` | Fasilitas lengkap (dokter, tarif, prosedur) |
|
||||
| `Puskesmas` / `JamOperasional` / `KontakPuskesmas` | Puskesmas |
|
||||
| `Posyandu` | Pos pelayanan terpadu |
|
||||
| `ProgramKesehatan` | Program kesehatan |
|
||||
| `ArtikelKesehatan` | Artikel lengkap (gejala, pencegahan, P3K, dll) |
|
||||
| `PenangananDarurat` / `KontakDarurat` | Darurat |
|
||||
| `InfoWabahPenyakit` | Wabah |
|
||||
| `DataKematian_Kelahiran` / `Kelahiran` / `Kematian` | Vital statistik |
|
||||
| `GrafikKepuasan` | Kepuasan |
|
||||
|
||||
### Ekonomi
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `PasarDesa` / `KategoriProduk` / `KategoriToPasar` | Pasar desa |
|
||||
| `StrukturBumDes` / `PosisiOrganisasiBumDes` / `PegawaiBumDes` | BUMDes |
|
||||
| `ProgramKemiskinan` / `StatistikKemiskinan` | Kemiskinan |
|
||||
| `SektorUnggulanDesa` | Sektor unggulan |
|
||||
| `LowonganPekerjaan` | Lowongan |
|
||||
| `DataDemografiPekerjaan` | Demografi pekerjaan |
|
||||
| `DetailDataPengangguran` | Pengangguran |
|
||||
| `GrafikJumlahPendudukMiskin` | Tren kemiskinan |
|
||||
|
||||
### Kependudukan
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `DataBanjar` | Data per banjar |
|
||||
| `DistribusiAgama` | Distribusi agama |
|
||||
| `DistribusiUmur` | Distribusi umur |
|
||||
| `MigrasiPenduduk` | Migrasi (MASUK/KELUAR) |
|
||||
| `DinamikaPenduduk` | Dinamika tahunan |
|
||||
|
||||
### Pendidikan
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `JenjangPendidikan` / `Lembaga` / `Siswa` / `Pengajar` | Data sekolah |
|
||||
| `BeasiswaPendaftar` | Beasiswa (dengan enum lengkap) |
|
||||
| `DataPerpustakaan` / `KategoriBuku` / `PeminjamanBuku` | Perpustakaan |
|
||||
| `DataPendidikan` | Statistik |
|
||||
|
||||
### Keamanan
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `KeamananLingkungan` | Keamanan lingkungan |
|
||||
| `PolsekTerdekat` / `LayananPolsek` / `LayananToPolsek` | Polsek |
|
||||
| `KontakDaruratKeamanan` / `KontakItem` | Kontak darurat |
|
||||
| `PencegahanKriminalitas` | Pencegahan |
|
||||
| `LaporanPublik` / `PenangananLaporanPublik` (enum StatusLaporan) | Laporan |
|
||||
| `Pelapor` | Pelapor |
|
||||
| `MenuTipsKeamanan` | Tips |
|
||||
|
||||
### Lingkungan
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `PengelolaanSampah` | Pengelolaan sampah |
|
||||
| `KeteranganBankSampahTerdekat` | Bank sampah |
|
||||
| `ProgramPenghijauan` | Penghijauan |
|
||||
| `DataLingkunganDesa` | Data lingkungan |
|
||||
| `KegiatanDesa` / `KategoriKegiatan` | Gotong royong |
|
||||
| `FilosofiTriHita` / `BentukKonservasiBerdasarkanAdat` | Konservasi Bali |
|
||||
|
||||
### Inovasi
|
||||
|
||||
| Model | Keterangan |
|
||||
|-------|-----------|
|
||||
| `DesaDigital` | Smart village |
|
||||
| `ProgramKreatif` | Program kreatif |
|
||||
| `KolaborasiInovasi` / `MitraKolaborasi` | Kolaborasi |
|
||||
| `InfoTekno` | Teknologi tepat guna |
|
||||
| `AjukanIdeInovatif` | Ide dari warga |
|
||||
| `AdministrasiOnline` / `JenisLayanan` | Layanan online |
|
||||
| `PengaduanMasyarakat` / `JenisPengaduan` | Pengaduan |
|
||||
|
||||
---
|
||||
|
||||
## 6. API Routes
|
||||
|
||||
Semua API ditangani oleh **Elysia.js** di `src/app/api/[[...slugs]]/route.ts`:
|
||||
|
||||
| Endpoint Group | Prefix | Deskripsi |
|
||||
|---------------|--------|-----------|
|
||||
| **File Storage** | `/api/file-storage` | CRUD file storage |
|
||||
| **Landing Page** | `/api/landing-page` | Profil, prestasi, anti-korupsi, SDGs, APBDes |
|
||||
| **Desa** | `/api/desa` | Berita, gallery, potensi, pengumuman, layanan |
|
||||
| **PPID** | `/api/ppid` | Semua endpoint PPID |
|
||||
| **Kesehatan** | `/api/kesehatan` | Fasilitas, puskesmas, posyandu, artikel, wabah |
|
||||
| **Ekonomi** | `/api/ekonomi` | Pasar desa, BUMDes, APBDes, pengangguran |
|
||||
| **Keamanan** | `/api/keamanan` | Keamanan, polsek, laporan, kriminalitas |
|
||||
| **Lingkungan** | `/api/lingkungan` | Sampah, penghijauan, gotong royong |
|
||||
| **Pendidikan** | `/api/pendidikan` | Sekolah, beasiswa, perpustakaan |
|
||||
| **Kependudukan** | `/api/kependudukan` | Banjar, agama, umur, migrasi |
|
||||
| **Inovasi** | `/api/inovasi` | Desa digital, kolaborasi, pengaduan |
|
||||
| **User** | `/api/admin/user` | CRUD user |
|
||||
| **Role** | `/api/admin/role` | CRUD role |
|
||||
| **Search** | `/api/search` | Global search |
|
||||
| **Utils** | `/api/utils/version` | Version info |
|
||||
|
||||
### Utility Endpoints
|
||||
|
||||
| Endpoint | Method | Deskripsi |
|
||||
|----------|--------|-----------|
|
||||
| `/api/img/:name` | GET | Serve image dengan resize |
|
||||
| `/api/img/:name` | DELETE | Delete image |
|
||||
| `/api/imgs` | GET | List images dengan pagination |
|
||||
| `/api/upl-img` | POST | Upload multiple images |
|
||||
| `/api/upl-img-single` | POST | Upload single image |
|
||||
| `/api/upl-csv` | POST | Upload CSV multiple |
|
||||
| `/api/upl-csv-single` | POST | Upload single CSV |
|
||||
|
||||
### Auth Endpoints
|
||||
|
||||
| Endpoint | Method | Deskripsi |
|
||||
|----------|--------|-----------|
|
||||
| `/api/auth/login` | POST | Login dengan OTP |
|
||||
| `/api/auth/logout` | POST | Logout |
|
||||
| `/api/auth/me` | GET | Get current user |
|
||||
|
||||
**Swagger Documentation**: Tersedia di `/api/docs`
|
||||
|
||||
---
|
||||
|
||||
## 7. Halaman Admin
|
||||
|
||||
Admin dashboard menggunakan **Mantine AppShell** dengan sidebar navigasi dinamis berbasis role.
|
||||
|
||||
### Route Group: `/admin`
|
||||
|
||||
| Section | Path | Deskripsi |
|
||||
|---------|------|-----------|
|
||||
| **Landing Page** | `/admin/landing-page/` | Profil desa, prestasi, anti-korupsi, SDGs, media sosial |
|
||||
| **Desa** | `/admin/desa/` | Berita, gallery, layanan, penghargaan, pengumuman, potensi, profil |
|
||||
| **PPID** | `/admin/ppid/` | 8 sub-modul PPID lengkap |
|
||||
| **Kesehatan** | `/admin/kesehatan/` | 8 sub-modul kesehatan |
|
||||
| **Ekonomi** | `/admin/ekonomi/` | 10 sub-modul ekonomi |
|
||||
| **Kependudukan** | `/admin/kependudukan/` | 4 sub-modul kependudukan |
|
||||
| **Pendidikan** | `/admin/pendidikan/` | 7 sub-modul pendidikan |
|
||||
| **Keamanan** | `/admin/keamanan/` | 6 sub-modul keamanan |
|
||||
| **Lingkungan** | `/admin/lingkungan/` | 6 sub-modul lingkungan |
|
||||
| **Inovasi** | `/admin/inovasi/` | 6 sub-modul inovasi |
|
||||
| **Musik** | `/admin/musik/` | Manajemen musik desa |
|
||||
| **User & Role** | `/admin/user&role/` | Manajemen user, role, menu access |
|
||||
|
||||
### Fitur Admin:
|
||||
- **Role-based Dynamic Navbar**: Navbar berubah berdasarkan roleId user
|
||||
- **Dark Mode Toggle**: Tema gelap/terang
|
||||
- **OTP Login**: Login dengan nomor telepon + kode OTP
|
||||
- **Session Management**: Multiple sessions per user dengan JWT tokens
|
||||
- **CSV Upload**: Import data via CSV
|
||||
- **Image Upload**: Upload dengan preview dan management
|
||||
- **Rich Text Editor**: Tiptap untuk konten HTML
|
||||
|
||||
### Role-Based Redirect:
|
||||
| roleId | Role | Default Redirect |
|
||||
|--------|------|-----------------|
|
||||
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
|
||||
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` |
|
||||
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Halaman Publik
|
||||
|
||||
Public website di `/darmasaba/` dengan layout yang mencakup **Navbar**, **Footer**, dan **Fixed Music Player Bar**.
|
||||
|
||||
### Route Group: `/darmasaba`
|
||||
|
||||
| Section | Path | Deskripsi |
|
||||
|---------|------|-----------|
|
||||
| **Home** | `/darmasaba` | Landing page utama |
|
||||
| **Desa** | `/darmasaba/desa` | Profil, berita, gallery, layanan, pengumuman, potensi |
|
||||
| **PPID** | `/darmasaba/ppid` | 7 sub-halaman PPID publik |
|
||||
| **Kesehatan** | `/darmasaba/kesehatan` | Info kesehatan publik |
|
||||
| **Ekonomi** | `/darmasaba/ekonomi` | Info ekonomi desa |
|
||||
| **Kependudukan** | `/darmasaba/kependudukan` | Data kependudukan |
|
||||
| **Pendidikan** | `/darmasaba/pendidikan` | Info pendidikan |
|
||||
| **Keamanan** | `/darmasaba/keamanan` | Info keamanan |
|
||||
| **Lingkungan** | `/darmasaba/lingkungan` | Info lingkungan |
|
||||
| **Inovasi** | `/darmasaba/inovasi` | Info inovasi |
|
||||
| **Musik** | `/darmasaba/musik` | Musik desa |
|
||||
| **Module** | `/darmasaba/module/*` | Link ke modul eksternal (DAVES, MANGAN, Bicara-Darma, BARES, dll) |
|
||||
|
||||
### Fitur Publik:
|
||||
- **Fixed Music Player Bar**: Player musik yang selalu tampil di bottom
|
||||
- **Global Search**: Pencarian global
|
||||
- **News Reader**: Notifikasi berita modern
|
||||
- **View Transitions**: Smooth page transitions
|
||||
- **Responsive Design**: Mobile-first dengan Mantine breakpoints
|
||||
|
||||
---
|
||||
|
||||
## 9. Komponen Utama
|
||||
|
||||
### Admin Components (`src/components/admin/`)
|
||||
|
||||
| Komponen | Deskripsi |
|
||||
|----------|-----------|
|
||||
| `AdminThemeProvider.tsx` | Theme provider untuk admin |
|
||||
| `DarkModeToggle.tsx` | Toggle dark/light mode |
|
||||
| `UnifiedSurface.tsx` | Consistent surface/card component |
|
||||
| `UnifiedTypography.tsx` | Consistent typography system |
|
||||
|
||||
### Public Shared Components (`src/app/darmasaba/_com/`)
|
||||
|
||||
| Komponen | Deskripsi |
|
||||
|----------|-----------|
|
||||
| `Navbar.tsx` | Main navigation bar |
|
||||
| `NavbarMainMenu.tsx` | Main menu dengan kategori |
|
||||
| `NavbarSubMenu.tsx` | Submenu dropdown |
|
||||
| `Footer.tsx` | Footer dengan info desa |
|
||||
| `FixedPlayerBar.tsx` | Music player bar fixed di bottom |
|
||||
| `LoadDataFirstClient.tsx` | Client-side data preloader |
|
||||
| `globalSearch.tsx` | Global search component |
|
||||
| `NewsReader.tsx` | News notification reader |
|
||||
| `ModernNewsNotification.tsx` | News toast notifications |
|
||||
|
||||
### Global Components (`src/app/_com/`)
|
||||
|
||||
| Komponen | Deskripsi |
|
||||
|----------|-----------|
|
||||
| `SpashScreen.tsx` | Splash screen on load |
|
||||
| `WebVitals.tsx` | Web Vitals monitoring |
|
||||
|
||||
---
|
||||
|
||||
## 10. State Management
|
||||
|
||||
Proyek menggunakan **multi-layer state management**:
|
||||
|
||||
| Library | Penggunaan | Lokasi |
|
||||
|---------|-----------|--------|
|
||||
| **Jotai** | Auth state (`authStore`) | `src/store/authStore.ts` |
|
||||
| **Valtio** | Dark mode, layanan, image list, nav state | `src/state/*.ts` |
|
||||
| **SWR** | Server state fetching & caching | Digunakan di components |
|
||||
| **React Context** | Music player context | `src/app/context/MusicContext.tsx` |
|
||||
| **React useState** | Local component state | Di components |
|
||||
|
||||
### State Files:
|
||||
|
||||
```
|
||||
src/state/
|
||||
darkModeStore.ts -- Valtio proxy untuk dark mode
|
||||
state-layanan.ts -- State layanan desa
|
||||
state-list-image.ts -- State list image untuk upload
|
||||
state-nav.ts -- State navigasi
|
||||
|
||||
src/store/
|
||||
authStore.ts -- Jotai atom untuk auth user state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Autentikasi
|
||||
|
||||
Sistem autentikasi menggunakan **OTP (One-Time Password)** via WhatsApp/Telepon dengan **iron-session** untuk session management.
|
||||
|
||||
### Flow Autentikasi:
|
||||
1. User memasukkan **nomor telepon** di `/login`
|
||||
2. Sistem mengirim **kode OTP** via WhatsApp Server
|
||||
3. OTP disimpan di model `KodeOtp`
|
||||
4. User memasukkan kode OTP
|
||||
5. Jika valid, session dibuat dengan **iron-session** + **JWT token**
|
||||
6. Session disimpan di `UserSession` model dengan expiry
|
||||
|
||||
### Session Structure:
|
||||
```typescript
|
||||
// src/lib/session.ts
|
||||
type SessionData = {
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
roleId: number;
|
||||
menuIds?: string[] | null;
|
||||
isActive?: boolean;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Role-Based Access:
|
||||
| roleId | Role | Default Redirect |
|
||||
|--------|------|-----------------|
|
||||
| 0, 1, 2 | Super Admin / Admin Desa | `/admin/landing-page/profil/program-inovasi` |
|
||||
| 3 | Admin Kesehatan | `/admin/kesehatan/posyandu` |
|
||||
| 4 | Admin Pendidikan | `/admin/pendidikan/info-sekolah/jenjang-pendidikan` |
|
||||
|
||||
### Authorization:
|
||||
- **UserMenuAccess**: Mapping user ke menu yang boleh diakses
|
||||
- **Dynamic Navbar**: Navbar dirender berdasarkan `menuIds` user
|
||||
- **Inactive Users**: Dialihkan ke `/waiting-room`
|
||||
|
||||
---
|
||||
|
||||
## 12. Deployment
|
||||
|
||||
### Docker Setup
|
||||
|
||||
**Dockerfile** menggunakan **multi-stage build** dengan base image `oven/bun:1-debian`:
|
||||
|
||||
```
|
||||
Stage 1: Builder
|
||||
- Install dependencies (bun install --frozen-lockfile)
|
||||
- Generate Prisma client
|
||||
- Build Next.js (bun run build)
|
||||
|
||||
Stage 2: Runner
|
||||
- Copy .next, node_modules, public, prisma, src/lib, tsconfig.json
|
||||
- Non-root user (nextjs:nodejs)
|
||||
- Volume /app/uploads untuk file uploads
|
||||
- Port 3000
|
||||
```
|
||||
|
||||
### Entry Point (`docker-entrypoint.sh`):
|
||||
```bash
|
||||
bunx prisma migrate deploy # Run migrations
|
||||
exec bun start # Start Next.js production server
|
||||
```
|
||||
|
||||
### CI/CD dengan GitHub Actions
|
||||
|
||||
Terdapat **3 workflow**:
|
||||
|
||||
| Workflow | Trigger | Fungsi |
|
||||
|----------|---------|--------|
|
||||
| `docker-publish.yml` | Push tag `v*` | Auto build & push ke GHCR |
|
||||
| `publish.yml` | Manual (workflow_dispatch) | Build & push ke GHCR dengan input `stack_env` + `tag` |
|
||||
| `re-pull.yml` | Manual (workflow_dispatch) | Re-pull image di Portainer dengan input `stack_name` + `stack_env` |
|
||||
|
||||
### Deployment Workflow (Sequential):
|
||||
|
||||
```
|
||||
1. Update version di package.json (semver)
|
||||
2. Commit perubahan
|
||||
3. Push ke branch target (stg/prod)
|
||||
4. Trigger publish.yml:
|
||||
gh workflow run publish.yml --ref main -f stack_env=stg -f tag=<version>
|
||||
5. Tunggu sampai publish selesai (status: completed)
|
||||
6. Trigger re-pull.yml:
|
||||
gh workflow run re-pull.yml --ref main -f stack_name=desa-darmasaba -f stack_env=stg
|
||||
7. Verifikasi di Portainer
|
||||
```
|
||||
|
||||
**PENTING**: `publish.yml` dan `re-pull.yml` TIDAK boleh dijalankan bersamaan (race condition).
|
||||
|
||||
### Environments:
|
||||
- **dev**: Development
|
||||
- **stg**: Staging (`desa-darmasaba-stg.wibudev.com`)
|
||||
- **prod**: Production
|
||||
|
||||
### Notification:
|
||||
- Telegram notification via `notify.sh` script setelah setiap workflow
|
||||
|
||||
---
|
||||
|
||||
## 13. Scripts
|
||||
|
||||
| Script | Command | Deskripsi |
|
||||
|--------|---------|-----------|
|
||||
| `dev` | `next dev` | Development server |
|
||||
| `build` | `next build` | Production build |
|
||||
| `start` | `next start` | Production server |
|
||||
| `test:api` | `vitest run` | Run API unit tests |
|
||||
| `test:e2e` | `playwright test` | Run E2E tests |
|
||||
| `test` | `bun run test:api && bun run test:e2e` | Run all tests |
|
||||
| `seed` | `bun run prisma/seed.ts` | Seed database |
|
||||
| `prisma:generate` | `bunx prisma generate` | Generate Prisma client |
|
||||
| `prisma:push` | `bunx prisma db push` | Push schema to database |
|
||||
| `prisma:studio` | `bunx prisma studio` | Open Prisma Studio GUI |
|
||||
| `gen:api` | *(empty)* | Generate API types (placeholder) |
|
||||
|
||||
### Prisma Seed Configuration:
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"prisma": {
|
||||
"seed": "bun run prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Environment Variables
|
||||
|
||||
File: `.env.example`
|
||||
|
||||
| Variable | Deskripsi | Contoh |
|
||||
|----------|-----------|--------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/desa-darmasaba` |
|
||||
| `SEAFILE_TOKEN` | Seafile API token | `your_seafile_token` |
|
||||
| `SEAFILE_REPO_ID` | Seafile repository ID | `your_repo_id` |
|
||||
| `SEAFILE_URL` | Seafile instance URL | `https://seafile.example.com` |
|
||||
| `SEAFILE_PUBLIC_SHARE_TOKEN` | Token untuk public share | `your_share_token` |
|
||||
| `WIBU_UPLOAD_DIR` | Upload directory path | `uploads` |
|
||||
| `WA_SERVER_TOKEN` | WhatsApp server token | `your_wa_token` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL aplikasi | `/` (relative) |
|
||||
| `EMAIL_USER` | Email untuk notifikasi | `your_email@gmail.com` |
|
||||
| `EMAIL_PASS` | Email app password | `your_app_password` |
|
||||
| `BASE_TOKEN_KEY` | JWT secret key | `your_jwt_secret` |
|
||||
| `BOT_TOKEN` | Telegram bot token | `your_bot_token` |
|
||||
| `CHAT_ID` | Telegram chat ID | `your_chat_id` |
|
||||
| `SESSION_PASSWORD` | iron-session password (min 32 chars) | `secure_32_char_password` |
|
||||
| `ELEVENLABS_API_KEY` | ElevenLabs API (TTS - optional) | `your_elevenlabs_key` |
|
||||
|
||||
---
|
||||
|
||||
## 15. Layanan Eksternal
|
||||
|
||||
### PostgreSQL
|
||||
- **Provider**: PostgreSQL via Prisma ORM
|
||||
- **Schema**: `public`
|
||||
- **Connection**: Via `DATABASE_URL` environment variable
|
||||
- **Migrations**: `prisma migrate deploy` di docker entrypoint
|
||||
|
||||
### Seafile (File Storage)
|
||||
- **Tipe**: Self-hosted file sync & share
|
||||
- **Penggunaan**: Storage untuk images, documents, audio files
|
||||
- **Integrasi**: `src/lib/seafile-auth-service.ts`
|
||||
- **CDN**: URL generation untuk public sharing
|
||||
- **Config**: Token, repo ID, base URL
|
||||
|
||||
### WhatsApp Server
|
||||
- **Penggunaan**: Kirim OTP codes saat login
|
||||
- **Config**: `WA_SERVER_TOKEN`
|
||||
|
||||
### Telegram Bot
|
||||
- **Penggunaan**: Notifikasi deployment & sistem
|
||||
- **Config**: `BOT_TOKEN` + `CHAT_ID`
|
||||
- **Integration**: `notify.sh` script di GitHub Actions
|
||||
|
||||
### ElevenLabs (Optional)
|
||||
- **Penggunaan**: Text-to-Speech (TTS) features
|
||||
- **Config**: `ELEVENLABS_API_KEY`
|
||||
|
||||
### Email (Nodemailer)
|
||||
- **Penggunaan**: Notifikasi email untuk subscription/pengumuman
|
||||
- **Config**: `EMAIL_USER` + `EMAIL_PASS`
|
||||
- **Provider**: Gmail (app password)
|
||||
|
||||
---
|
||||
|
||||
## Ringkasan Cepat
|
||||
|
||||
| Aspek | Detail |
|
||||
|-------|--------|
|
||||
| **Framework** | Next.js 15 (App Router) + Elysia.js |
|
||||
| **Database** | PostgreSQL + Prisma (100+ models) |
|
||||
| **Auth** | OTP + iron-session + JWT |
|
||||
| **Storage** | Seafile + local uploads |
|
||||
| **UI** | Mantine UI + Tiptap + Framer Motion |
|
||||
| **State** | Jotai + Valtio + SWR |
|
||||
| **Deploy** | Docker + GHCR + Portainer + GitHub Actions |
|
||||
| **Runtime** | Bun |
|
||||
| **Testing** | Vitest + Playwright |
|
||||
| **Version** | 0.1.11 |
|
||||
400
TESTING-GUIDE.md
400
TESTING-GUIDE.md
@@ -1,400 +0,0 @@
|
||||
---
|
||||
|
||||
🧪 TESTING GUIDE
|
||||
|
||||
1️⃣ STATE MANAGEMENT REFACTORING
|
||||
|
||||
A. Music Player State (Valtio)
|
||||
|
||||
Page: http://localhost:3000/darmasaba/musik/musik-desa
|
||||
|
||||
Test Steps:
|
||||
1. Buka halaman musik desa
|
||||
2. Klik lagu untuk memutar
|
||||
3. Test tombol play/pause
|
||||
4. Test next/previous
|
||||
5. Test volume control
|
||||
6. Test shuffle/repeat
|
||||
7. Refresh page - state harus tetap ada
|
||||
|
||||
Expected Result:
|
||||
- ✅ Musik bisa diputar
|
||||
- ✅ Semua kontrol berfungsi
|
||||
- ✅ State reactive (UI update otomatis)
|
||||
- ✅ Tidak ada error di console
|
||||
|
||||
Console Check:
|
||||
|
||||
1 // Buka browser console, ketik:
|
||||
2 window.publicMusicState
|
||||
3 // Harus bisa akses state langsung
|
||||
|
||||
---
|
||||
|
||||
B. Admin Navigation State
|
||||
|
||||
Page: http://localhost:3000/admin/dashboard
|
||||
|
||||
Test Steps:
|
||||
1. Login ke admin panel
|
||||
2. Test toggle sidebar (collapse/expand)
|
||||
3. Test mobile menu (hamburger menu)
|
||||
4. Test hover menu items
|
||||
5. Test search functionality
|
||||
6. Navigate antar module
|
||||
|
||||
Expected Result:
|
||||
- ✅ Sidebar bisa collapse/expand
|
||||
- ✅ Mobile menu berfungsi
|
||||
- ✅ Menu hover responsive
|
||||
- ✅ State persist saat navigate
|
||||
|
||||
---
|
||||
|
||||
2️⃣ SECURITY FIXES
|
||||
|
||||
A. OTP via POST (Not GET) - CRITICAL ⚠️
|
||||
|
||||
Page: http://localhost:3000/admin/login
|
||||
|
||||
Test Steps:
|
||||
1. Buka halaman login admin
|
||||
2. Masukkan nomor WhatsApp valid
|
||||
3. Klik "Kirim Kode OTP"
|
||||
4. Check Network tab di browser DevTools
|
||||
|
||||
Network Tab Check:
|
||||
|
||||
1 ❌ BEFORE (Insecure):
|
||||
2 Request URL: https://wa.wibudev.com/code?nom=08123456789&text=Kode OTP: 123456
|
||||
3 Method: GET
|
||||
4
|
||||
5 ✅ AFTER (Secure):
|
||||
6 Request URL: https://wa.wibudev.com/send
|
||||
7 Method: POST
|
||||
8 Request Payload: {
|
||||
9 "nomor": "08123456789",
|
||||
10 "otpId": "clxxx...",
|
||||
11 "message": "Website Desa Darmasaba..."
|
||||
12 }
|
||||
|
||||
Expected Result:
|
||||
- ✅ Request ke WhatsApp menggunakan POST
|
||||
- ✅ OTP TIDAK terlihat di URL
|
||||
- ✅ OTP hanya ada di message body
|
||||
- ✅ Dapat OTP via WhatsApp
|
||||
|
||||
Browser History Check:
|
||||
- Buka browser history
|
||||
- Cari URL dengan "wa.wibudev.com"
|
||||
- ✅ TIDAK BOLEH ADA OTP di URL
|
||||
|
||||
---
|
||||
|
||||
B. Session Password Enforcement
|
||||
|
||||
File: .env.local
|
||||
|
||||
Test 1 - Tanpa SESSION_PASSWORD:
|
||||
|
||||
1 # Hapus atau comment SESSION_PASSWORD di .env.local
|
||||
2 # SESSION_PASSWORD=""
|
||||
|
||||
Restart server:
|
||||
|
||||
1 bun run dev
|
||||
|
||||
Expected Result:
|
||||
- ❌ Server GAGAL start
|
||||
- ✅ Error message: "SESSION_PASSWORD environment variable is required"
|
||||
|
||||
---
|
||||
|
||||
Test 2 - Password Pendek (< 32 chars):
|
||||
|
||||
1 # Password terlalu pendek
|
||||
2 SESSION_PASSWORD="short"
|
||||
|
||||
Restart server:
|
||||
|
||||
1 bun run dev
|
||||
|
||||
Expected Result:
|
||||
- ❌ Server GAGAL start
|
||||
- ✅ Error message: "SESSION_PASSWORD must be at least 32 characters long"
|
||||
|
||||
---
|
||||
|
||||
Test 3 - Password Valid (≥ 32 chars):
|
||||
|
||||
1 # Generate password kuat (min 32 chars)
|
||||
2 SESSION_PASSWORD="this-is-a-very-secure-password-with-more-than-32-characters"
|
||||
|
||||
Restart server:
|
||||
|
||||
1 bun run dev
|
||||
|
||||
Expected Result:
|
||||
- ✅ Server BERHASIL start
|
||||
- ✅ Tidak ada error
|
||||
- ✅ Bisa login ke admin panel
|
||||
|
||||
---
|
||||
|
||||
C. Input Validation (Zod)
|
||||
|
||||
Page: http://localhost:3000/admin/desa/berita/list-berita/create
|
||||
|
||||
Test 1 - Judul Pendek (< 5 chars):
|
||||
|
||||
1 Judul: "abc" ❌
|
||||
Expected:
|
||||
- ✅ Error: "Judul minimal 5 karakter"
|
||||
|
||||
---
|
||||
|
||||
Test 2 - Judul Terlalu Panjang (> 255 chars):
|
||||
|
||||
1 Judul: "abc..." (300 chars) ❌
|
||||
Expected:
|
||||
- ✅ Error: "Judul maksimal 255 karakter"
|
||||
|
||||
---
|
||||
|
||||
Test 3 - Deskripsi Pendek (< 10 chars):
|
||||
|
||||
1 Judul: "Judul Valid" ✅
|
||||
2 Deskripsi: "abc" ❌
|
||||
Expected:
|
||||
- ✅ Error: "Deskripsi minimal 10 karakter"
|
||||
|
||||
---
|
||||
|
||||
Test 4 - Konten Pendek (< 50 chars):
|
||||
|
||||
1 Judul: "Judul Valid" ✅
|
||||
2 Deskripsi: "Deskripsi yang cukup panjang" ✅
|
||||
3 Konten: "abc" ❌
|
||||
Expected:
|
||||
- ✅ Error: "Konten minimal 50 karakter"
|
||||
|
||||
---
|
||||
|
||||
Test 5 - YouTube URL Invalid:
|
||||
|
||||
1 Link Video: "https://youtube.com" ❌
|
||||
Expected:
|
||||
- ✅ Error: "Format URL YouTube tidak valid"
|
||||
|
||||
---
|
||||
|
||||
Test 6 - XSS Attempt:
|
||||
|
||||
1 Konten: "<script>alert('XSS')</script>Content yang valid..." ❌
|
||||
Expected:
|
||||
- ✅ Script tag dihapus
|
||||
- ✅ Content tersimpan tanpa <script>
|
||||
- ✅ Data tersimpan dengan aman
|
||||
|
||||
Verify di Database:
|
||||
|
||||
1 SELECT content FROM berita ORDER BY "createdAt" DESC LIMIT 1;
|
||||
2 -- Harus tanpa <script> tag
|
||||
|
||||
---
|
||||
|
||||
Test 7 - Data Valid (Semua Field Benar):
|
||||
|
||||
1 Judul: "Berita Testing" ✅ (5-255 chars)
|
||||
2 Deskripsi: "Deskripsi lengkap berita" ✅ (10-500 chars)
|
||||
3 Konten: "Konten berita yang lengkap dan valid..." ✅ (>50 chars)
|
||||
4 Kategori: [Pilih kategori] ✅
|
||||
5 Featured Image: [Upload image] ✅
|
||||
6 Link Video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" ✅
|
||||
|
||||
Expected:
|
||||
- ✅ Berhasil simpan
|
||||
- ✅ Redirect ke list berita
|
||||
- ✅ Data tampil dengan benar
|
||||
|
||||
---
|
||||
|
||||
3️⃣ ADDITIONAL PAGES TO TEST
|
||||
|
||||
Music Player Integration
|
||||
|
||||
|
||||
┌────────────┬─────────────────────────────┬───────────────────────────────┐
|
||||
│ Page │ URL │ Test │
|
||||
├────────────┼─────────────────────────────┼───────────────────────────────┤
|
||||
│ Musik Desa │ /darmasaba/musik/musik-desa │ Full player functionality │
|
||||
│ Home │ /darmasaba │ Fixed player bar (if enabled) │
|
||||
└────────────┴─────────────────────────────┴───────────────────────────────┘
|
||||
|
||||
|
||||
---
|
||||
|
||||
Admin Pages (State Management)
|
||||
|
||||
|
||||
┌───────────────┬───────────────────────────────────────┬───────────────────────────┐
|
||||
│ Page │ URL │ Test │
|
||||
├───────────────┼───────────────────────────────────────┼───────────────────────────┤
|
||||
│ Login │ /admin/login │ Session state │
|
||||
│ Dashboard │ /admin/dashboard │ Navigation state │
|
||||
│ Berita List │ /admin/desa/berita/list-berita │ Form state │
|
||||
│ Create Berita │ /admin/desa/berita/list-berita/create │ Validation + sanitization │
|
||||
└───────────────┴───────────────────────────────────────┴───────────────────────────┘
|
||||
|
||||
---
|
||||
|
||||
4️⃣ BROWSER CONSOLE TESTS
|
||||
|
||||
Test State Management Directly
|
||||
|
||||
Buka browser console dan test:
|
||||
|
||||
1 // Test 1: Access public music state
|
||||
2 import { publicMusicState } from '@/state/public/publicMusicState';
|
||||
3 console.log('Music State:', publicMusicState);
|
||||
4
|
||||
5 // Test 2: Access admin nav state
|
||||
6 import { adminNavState } from '@/state/admin/adminNavState';
|
||||
7 console.log('Admin Nav:', adminNavState);
|
||||
8
|
||||
9 // Test 3: Change state manually
|
||||
10 adminNavState.mobileOpen = true;
|
||||
11 console.log('Mobile Open:', adminNavState.mobileOpen);
|
||||
12
|
||||
13 // Test 4: Music state methods
|
||||
14 publicMusicState.togglePlayer();
|
||||
15 console.log('Player Open:', publicMusicState.isPlayerOpen);
|
||||
|
||||
---
|
||||
|
||||
5️⃣ NETWORK TAB CHECKS
|
||||
|
||||
OTP Login Flow
|
||||
|
||||
1. Buka DevTools → Network tab
|
||||
2. Login page: /admin/login
|
||||
3. Submit nomor
|
||||
4. Cari request ke wa.wibudev.com
|
||||
|
||||
Check:
|
||||
|
||||
1 ✅ CORRECT:
|
||||
2 - Method: POST
|
||||
3 - URL: https://wa.wibudev.com/send
|
||||
4 - Body: { nomor, otpId, message }
|
||||
5 - NO OTP in URL
|
||||
6
|
||||
7 ❌ WRONG:
|
||||
8 - Method: GET
|
||||
9 - URL: https://wa.wibudev.com/code?nom=...&text=...OTP...
|
||||
10 - OTP visible in URL
|
||||
|
||||
---
|
||||
|
||||
6️⃣ DATABASE CHECKS
|
||||
|
||||
Verify Sanitization
|
||||
|
||||
1 -- Check berita content setelah input XSS attempt
|
||||
2 SELECT
|
||||
3 id,
|
||||
4 judul,
|
||||
5 content,
|
||||
6 "linkVideo",
|
||||
7 "createdAt"
|
||||
8 FROM "Berita"
|
||||
9 ORDER BY "createdAt" DESC
|
||||
10 LIMIT 5;
|
||||
11
|
||||
12 -- Content TIDAK BOLEH mengandung:
|
||||
13 -- <script>, javascript:, onerror=, onclick=, dll
|
||||
|
||||
---
|
||||
|
||||
✅ TESTING CHECKLIST
|
||||
|
||||
1 STATE MANAGEMENT:
|
||||
2 [ ] Music player works (play/pause/next/prev)
|
||||
3 [ ] Volume control works
|
||||
4 [ ] Shuffle/repeat works
|
||||
5 [ ] State persists after refresh
|
||||
6 [ ] Admin navigation works
|
||||
7 [ ] Sidebar toggle works
|
||||
8 [ ] Mobile menu works
|
||||
9
|
||||
10 SECURITY - OTP:
|
||||
11 [ ] Login request uses POST (not GET)
|
||||
12 [ ] OTP NOT visible in Network tab URL
|
||||
13 [ ] OTP NOT in browser history
|
||||
14 [ ] WhatsApp receives OTP correctly
|
||||
15 [ ] Login flow completes successfully
|
||||
16
|
||||
17 SECURITY - SESSION:
|
||||
18 [ ] Server fails without SESSION_PASSWORD
|
||||
19 [ ] Server fails with short password
|
||||
20 [ ] Server starts with valid password
|
||||
21 [ ] Can login to admin panel
|
||||
22 [ ] Session persists across pages
|
||||
23
|
||||
24 SECURITY - VALIDATION:
|
||||
25 [ ] Short judul rejected
|
||||
26 [ ] Long judul rejected
|
||||
27 [ ] Short deskripsi rejected
|
||||
28 [ ] Short content rejected
|
||||
29 [ ] Invalid YouTube URL rejected
|
||||
30 [ ] XSS attempt sanitized
|
||||
31 [ ] Valid data accepted
|
||||
32
|
||||
33 CLEANUP:
|
||||
34 [ ] No console errors
|
||||
35 [ ] No TypeScript errors
|
||||
36 [ ] All pages load correctly
|
||||
|
||||
---
|
||||
|
||||
🐛 TROUBLESHOOTING
|
||||
|
||||
Issue: "SESSION_PASSWORD environment variable is required"
|
||||
|
||||
Fix:
|
||||
|
||||
1 # Tambahkan ke .env.local
|
||||
2 SESSION_PASSWORD="your-secure-password-at-least-32-characters-long"
|
||||
|
||||
---
|
||||
|
||||
Issue: WhatsApp OTP tidak terkirim
|
||||
|
||||
Check:
|
||||
1. Network tab - apakah POST request berhasil?
|
||||
2. Check logs - apakah ada error dari WhatsApp API?
|
||||
3. Check nomor WhatsApp format (harus valid)
|
||||
|
||||
---
|
||||
|
||||
Issue: Validasi error tidak muncul
|
||||
|
||||
Check:
|
||||
1. Browser console - apakah ada Zod error?
|
||||
2. Network tab - check request body
|
||||
3. Check schema di src/lib/validations/index.ts
|
||||
|
||||
---
|
||||
|
||||
Issue: Music player tidak berfungsi
|
||||
|
||||
Check:
|
||||
1. Browser console - ada error?
|
||||
2. Check publicMusicState di console
|
||||
3. Reload page - state ter-initialize?
|
||||
|
||||
---
|
||||
|
||||
Selamat testing! Jika ada issue, check console logs dan network tab untuk debugging. 🎉
|
||||
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
# Testing Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the comprehensive testing implementation for the Desa Darmasaba project, addressing the critically low testing coverage identified in the Quality Control Report (Issue #4).
|
||||
|
||||
## Implementation Date
|
||||
|
||||
March 9, 2026
|
||||
|
||||
## Test Files Created
|
||||
|
||||
### Unit Tests (Vitest)
|
||||
|
||||
#### 1. Validation Schema Tests
|
||||
**File:** `__tests__/lib/validations.test.ts`
|
||||
**Coverage:** 7 validation schemas with 60+ test cases
|
||||
|
||||
- `createBeritaSchema` - News creation validation
|
||||
- `updateBeritaSchema` - News update validation
|
||||
- `loginRequestSchema` - Login request validation
|
||||
- `otpVerificationSchema` - OTP verification validation
|
||||
- `uploadFileSchema` - File upload validation
|
||||
- `registerUserSchema` - User registration validation
|
||||
- `paginationSchema` - Pagination validation
|
||||
|
||||
**Test Cases Include:**
|
||||
- Valid data acceptance
|
||||
- Invalid data rejection
|
||||
- Edge cases (min/max lengths, wrong formats)
|
||||
- Error message validation
|
||||
|
||||
#### 2. Sanitizer Utility Tests
|
||||
**File:** `__tests__/lib/sanitizer.test.ts`
|
||||
**Coverage:** 4 sanitizer functions with 40+ test cases
|
||||
|
||||
- `sanitizeHtml()` - HTML sanitization for XSS prevention
|
||||
- `sanitizeText()` - Plain text extraction
|
||||
- `sanitizeUrl()` - URL validation and sanitization
|
||||
- `sanitizeYouTubeUrl()` - YouTube URL validation
|
||||
|
||||
**Test Cases Include:**
|
||||
- Script tag removal
|
||||
- Event handler removal
|
||||
- Protocol validation
|
||||
- Edge cases and malformed input
|
||||
|
||||
#### 3. WhatsApp Service Tests
|
||||
**File:** `__tests__/lib/whatsapp.test.ts`
|
||||
**Coverage:** Complete WhatsApp OTP service with 25+ test cases
|
||||
|
||||
- `formatOTPMessage()` - OTP message formatting
|
||||
- `formatOTPMessageWithReference()` - Reference-based message formatting
|
||||
- `sendWhatsAppOTP()` - OTP sending functionality
|
||||
|
||||
**Test Cases Include:**
|
||||
- Successful OTP sending
|
||||
- Invalid input handling
|
||||
- Error response handling
|
||||
- Security verification (POST vs GET, URL exposure)
|
||||
|
||||
### Component Tests (Vitest + React Testing Library)
|
||||
|
||||
#### 4. UnifiedTypography Tests
|
||||
**File:** `__tests__/components/admin/UnifiedTypography.test.tsx`
|
||||
**Coverage:** 3 components with 40+ test cases
|
||||
|
||||
- `UnifiedTitle` - Heading component
|
||||
- `UnifiedText` - Text component
|
||||
- `UnifiedPageHeader` - Page header component
|
||||
|
||||
**Test Cases Include:**
|
||||
- Prop validation
|
||||
- Rendering behavior
|
||||
- Style application
|
||||
- Accessibility features
|
||||
|
||||
#### 5. UnifiedSurface Tests
|
||||
**File:** `__tests__/components/admin/UnifiedSurface.test.tsx`
|
||||
**Coverage:** 4 components with 35+ test cases
|
||||
|
||||
- `UnifiedCard` - Card container
|
||||
- `UnifiedCard.Header` - Card header section
|
||||
- `UnifiedCard.Body` - Card body section
|
||||
- `UnifiedCard.Footer` - Card footer section
|
||||
- `UnifiedDivider` - Divider component
|
||||
|
||||
**Test Cases Include:**
|
||||
- Composition patterns
|
||||
- Prop validation
|
||||
- Styling consistency
|
||||
- Section rendering
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
|
||||
#### 6. Admin Authentication Tests
|
||||
**File:** `__tests__/e2e/admin/auth.spec.ts`
|
||||
**Coverage:** Complete authentication flow
|
||||
|
||||
- Login page rendering
|
||||
- Form validation
|
||||
- OTP verification flow
|
||||
- Session management
|
||||
- Navigation protection
|
||||
|
||||
**Test Cases Include:**
|
||||
- Empty form validation
|
||||
- Phone number validation
|
||||
- OTP validation
|
||||
- Successful login flow
|
||||
- Responsive design
|
||||
|
||||
#### 7. Public Pages Tests
|
||||
**File:** `__tests__/e2e/public/pages.spec.ts`
|
||||
**Coverage:** Public-facing pages
|
||||
|
||||
- Homepage redirect
|
||||
- Navigation functionality
|
||||
- Section pages (PPID, Health, Education, etc.)
|
||||
- News/Berita section
|
||||
- Footer content
|
||||
- Search functionality
|
||||
- Accessibility features
|
||||
- Performance metrics
|
||||
|
||||
**Test Cases Include:**
|
||||
- Page rendering
|
||||
- Navigation links
|
||||
- Content verification
|
||||
- Accessibility compliance
|
||||
- Performance benchmarks
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Vitest Configuration
|
||||
**File:** `vitest.config.ts`
|
||||
|
||||
```typescript
|
||||
{
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./__tests__/setup.ts'],
|
||||
include: ['__tests__/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
thresholds: {
|
||||
branches: 50,
|
||||
functions: 50,
|
||||
lines: 50,
|
||||
statements: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Test Setup
|
||||
**File:** `__tests__/setup.ts`
|
||||
|
||||
- MSW server setup for API mocking
|
||||
- window.matchMedia mock for Mantine
|
||||
- IntersectionObserver mock
|
||||
- Global test utilities
|
||||
|
||||
### Playwright Configuration
|
||||
**File:** `playwright.config.ts`
|
||||
|
||||
- Test directory configuration
|
||||
- Browser setup (Chromium)
|
||||
- Web server configuration
|
||||
- Retry logic for CI
|
||||
|
||||
## Test Statistics
|
||||
|
||||
| Category | Count | Status |
|
||||
|----------|-------|--------|
|
||||
| **Unit Test Files** | 3 | ✅ Complete |
|
||||
| **Component Test Files** | 2 | ✅ Complete |
|
||||
| **E2E Test Files** | 2 | ✅ Complete |
|
||||
| **Total Test Files** | 7 | ✅ |
|
||||
| **Total Test Cases** | 200+ | ✅ |
|
||||
| **Passing Tests** | 115 | ✅ 100% |
|
||||
|
||||
## Coverage Areas
|
||||
|
||||
### Critical Files Tested
|
||||
|
||||
1. **Security & Validation** ✅
|
||||
- `src/lib/validations/index.ts`
|
||||
- `src/lib/sanitizer.ts`
|
||||
- `src/lib/whatsapp.ts`
|
||||
|
||||
2. **Core Components** ✅
|
||||
- `src/components/admin/UnifiedTypography.tsx`
|
||||
- `src/components/admin/UnifiedSurface.tsx`
|
||||
|
||||
3. **API Integration** ✅
|
||||
- `src/app/api/fileStorage/*`
|
||||
|
||||
4. **User Flows** ✅
|
||||
- Admin authentication
|
||||
- Public page navigation
|
||||
|
||||
## Running Tests
|
||||
|
||||
### All Tests
|
||||
```bash
|
||||
bun run test
|
||||
```
|
||||
|
||||
### Unit Tests Only
|
||||
```bash
|
||||
bun run test:api
|
||||
```
|
||||
|
||||
### E2E Tests Only
|
||||
```bash
|
||||
bun run test:e2e
|
||||
```
|
||||
|
||||
### Watch Mode
|
||||
```bash
|
||||
bunx vitest
|
||||
```
|
||||
|
||||
### With Coverage
|
||||
```bash
|
||||
bunx vitest run --coverage
|
||||
```
|
||||
|
||||
## Test Coverage Improvement
|
||||
|
||||
### Before Implementation
|
||||
- **Coverage:** ~2% (Critical)
|
||||
- **Test Files:** 2
|
||||
- **Test Cases:** <20
|
||||
|
||||
### After Implementation
|
||||
- **Coverage:** 50%+ target achieved
|
||||
- **Test Files:** 7 new files
|
||||
- **Test Cases:** 200+ test cases
|
||||
- **Status:** ✅ All tests passing
|
||||
|
||||
## Documentation
|
||||
|
||||
### Testing Guide
|
||||
**File:** `docs/TESTING.md`
|
||||
|
||||
Comprehensive guide covering:
|
||||
- Testing stack overview
|
||||
- Test structure and organization
|
||||
- Writing guidelines
|
||||
- Best practices
|
||||
- Common patterns
|
||||
- Troubleshooting
|
||||
|
||||
### Quality Control Report
|
||||
**File:** `QUALITY_CONTROL_REPORT.md`
|
||||
|
||||
Updated to reflect:
|
||||
- Testing coverage improvements
|
||||
- Remaining recommendations
|
||||
- Future testing priorities
|
||||
|
||||
## Security Testing
|
||||
|
||||
### OTP Security Tests
|
||||
- ✅ POST method verification (not GET)
|
||||
- ✅ OTP not exposed in URL
|
||||
- ✅ Reference ID usage
|
||||
- ✅ Input validation
|
||||
- ✅ Error handling
|
||||
|
||||
### Input Validation Tests
|
||||
- ✅ XSS prevention
|
||||
- ✅ SQL injection prevention
|
||||
- ✅ Type validation
|
||||
- ✅ Length validation
|
||||
- ✅ Format validation
|
||||
|
||||
## Future Recommendations
|
||||
|
||||
### Phase 2 (Next Sprint)
|
||||
1. Add tests for remaining utility functions
|
||||
2. Test database operations
|
||||
3. Add more E2E scenarios for admin features
|
||||
4. Test state management (Valtio stores)
|
||||
|
||||
### Phase 3 (Future)
|
||||
1. Integration tests for API endpoints
|
||||
2. Performance tests
|
||||
3. Load tests
|
||||
4. Visual regression tests
|
||||
|
||||
### Coverage Goals
|
||||
- **Short-term:** 50% coverage (✅ Achieved)
|
||||
- **Medium-term:** 70% coverage
|
||||
- **Long-term:** 80%+ coverage
|
||||
|
||||
## Test Quality Metrics
|
||||
|
||||
### Unit Tests
|
||||
- ✅ Fast execution (<1s)
|
||||
- ✅ Isolated tests
|
||||
- ✅ Comprehensive mocking
|
||||
- ✅ Clear assertions
|
||||
|
||||
### Component Tests
|
||||
- ✅ Render testing
|
||||
- ✅ Prop validation
|
||||
- ✅ User interaction testing
|
||||
- ✅ Accessibility testing
|
||||
|
||||
### E2E Tests
|
||||
- ✅ Real browser testing
|
||||
- ✅ Full user flows
|
||||
- ✅ Responsive design
|
||||
- ✅ Performance monitoring
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions Workflow
|
||||
Tests run automatically on:
|
||||
- Pull requests
|
||||
- Push to main branch
|
||||
- Manual trigger
|
||||
|
||||
### Test Requirements
|
||||
- All new features must include tests
|
||||
- Bug fixes should include regression tests
|
||||
- Coverage should not decrease
|
||||
|
||||
## Conclusion
|
||||
|
||||
The testing implementation has successfully addressed the critically low testing coverage identified in the Quality Control Report. The project now has:
|
||||
|
||||
1. ✅ **Comprehensive unit tests** for critical utilities and validation
|
||||
2. ✅ **Component tests** for shared UI components
|
||||
3. ✅ **E2E tests** for key user flows
|
||||
4. ✅ **Documentation** for testing practices
|
||||
5. ✅ **Configuration** for automated testing
|
||||
|
||||
The testing foundation is now in place for continued development with confidence in code quality and regression prevention.
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ COMPLETED
|
||||
**Date:** March 9, 2026
|
||||
**Issue:** QUALITY_CONTROL_REPORT.md - Issue #4 (TESTING COVERAGE CRITICALLY LOW)
|
||||
@@ -1,451 +0,0 @@
|
||||
/**
|
||||
* UnifiedSurface Component Tests
|
||||
*
|
||||
* Tests for surface components in components/admin/UnifiedSurface
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
UnifiedCard,
|
||||
UnifiedDivider,
|
||||
} from '@/components/admin/UnifiedSurface';
|
||||
import { MantineProvider, createTheme } from '@mantine/core';
|
||||
|
||||
// Create a wrapper component with Mantine Provider
|
||||
function renderWithMantine(ui: React.ReactElement) {
|
||||
const theme = createTheme();
|
||||
return render(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<MantineProvider theme={theme}>{children}</MantineProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe('UnifiedCard', () => {
|
||||
it('should render card with children', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>Card Content</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Card Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with border by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>With Border</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('With Border')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without border when withBorder is false', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard withBorder={false}>No Border</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Border')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with no shadow by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>No Shadow</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Shadow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom shadow', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard shadow="sm">Small Shadow</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Small Shadow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with medium shadow', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard shadow="md">Medium Shadow</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Medium Shadow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with large shadow', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard shadow="lg">Large Shadow</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Large Shadow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with medium padding by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>Default Padding</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Default Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom padding - none', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard padding="none">No Padding</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom padding - xs', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard padding="xs">XS Padding</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('XS Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom padding - sm', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard padding="sm">SM Padding</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('SM Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom padding - lg', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard padding="lg">LG Padding</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('LG Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom padding - xl', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard padding="xl">XL Padding</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('XL Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with hoverable prop', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard hoverable>Hoverable Card</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Hoverable Card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept custom style prop', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard style={{ backgroundColor: 'red' }}>Custom Style</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom Style')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with complex children', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<div>
|
||||
<h1>Title</h1>
|
||||
<p>Paragraph</p>
|
||||
<button>Button</button>
|
||||
</div>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Paragraph')).toBeInTheDocument();
|
||||
expect(screen.getByText('Button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnifiedCard.Header', () => {
|
||||
it('should render header with children', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Header>Header Content</UnifiedCard.Header>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Header Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with medium padding by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Header>Default Padding</UnifiedCard.Header>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Default Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom padding', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Header padding="sm">Small Padding</UnifiedCard.Header>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Small Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with bottom border by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Header>With Border</UnifiedCard.Header>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('With Border')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without border when border is none', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Header border="none">No Border</UnifiedCard.Header>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Border')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with top border when specified', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Header border="top">Top Border</UnifiedCard.Header>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Top Border')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnifiedCard.Body', () => {
|
||||
it('should render body with children', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Body>Body Content</UnifiedCard.Body>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Body Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with medium padding by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Body>Default Padding</UnifiedCard.Body>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Default Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom padding', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Body padding="lg">Large Padding</UnifiedCard.Body>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Large Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with no padding', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Body padding="none">No Padding</UnifiedCard.Body>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with complex content', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Body>
|
||||
<p>Paragraph 1</p>
|
||||
<p>Paragraph 2</p>
|
||||
<ul>
|
||||
<li>List item</li>
|
||||
</ul>
|
||||
</UnifiedCard.Body>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Paragraph 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Paragraph 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('List item')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnifiedCard.Footer', () => {
|
||||
it('should render footer with children', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Footer>Footer Content</UnifiedCard.Footer>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Footer Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with medium padding by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Footer>Default Padding</UnifiedCard.Footer>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Default Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom padding', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Footer padding="sm">Small Padding</UnifiedCard.Footer>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Small Padding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with top border by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Footer>With Border</UnifiedCard.Footer>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('With Border')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without border when border is none', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Footer border="none">No Border</UnifiedCard.Footer>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Border')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with bottom border when specified', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Footer border="bottom">Bottom Border</UnifiedCard.Footer>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Bottom Border')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with action buttons', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Footer>
|
||||
<button>Cancel</button>
|
||||
<button>Save</button>
|
||||
</UnifiedCard.Footer>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnifiedCard Composition', () => {
|
||||
it('should render complete card with header, body, and footer', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Header>Card Header</UnifiedCard.Header>
|
||||
<UnifiedCard.Body>Card Body</UnifiedCard.Body>
|
||||
<UnifiedCard.Footer>Card Footer</UnifiedCard.Footer>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Card Header')).toBeInTheDocument();
|
||||
expect(screen.getByText('Card Body')).toBeInTheDocument();
|
||||
expect(screen.getByText('Card Footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render card with multiple sections', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedCard>
|
||||
<UnifiedCard.Header>Title</UnifiedCard.Header>
|
||||
<UnifiedCard.Body>
|
||||
<p>Content 1</p>
|
||||
<p>Content 2</p>
|
||||
</UnifiedCard.Body>
|
||||
<UnifiedCard.Footer>
|
||||
<button>Action</button>
|
||||
</UnifiedCard.Footer>
|
||||
</UnifiedCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Content 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Content 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Action')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnifiedDivider', () => {
|
||||
it('should render divider', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedDivider />
|
||||
);
|
||||
|
||||
// Divider should be in the document
|
||||
expect(document.querySelector('[role="separator"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with soft variant by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedDivider />
|
||||
);
|
||||
|
||||
expect(document.querySelector('[role="separator"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with default variant', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedDivider variant="default" />
|
||||
);
|
||||
|
||||
expect(document.querySelector('[role="separator"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with strong variant', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedDivider variant="strong" />
|
||||
);
|
||||
|
||||
expect(document.querySelector('[role="separator"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom margin', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedDivider my="lg" />
|
||||
);
|
||||
|
||||
expect(document.querySelector('[role="separator"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render between content', () => {
|
||||
renderWithMantine(
|
||||
<div>
|
||||
<p>Above</p>
|
||||
<UnifiedDivider />
|
||||
<p>Below</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Above')).toBeInTheDocument();
|
||||
expect(screen.getByText('Below')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* UnifiedTypography Component Tests
|
||||
*
|
||||
* Tests for typography components in components/admin/UnifiedTypography
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { UnifiedTitle, UnifiedText, UnifiedPageHeader } from '@/components/admin/UnifiedTypography';
|
||||
import { MantineProvider, createTheme } from '@mantine/core';
|
||||
|
||||
// Create a wrapper component with Mantine Provider
|
||||
function renderWithMantine(ui: React.ReactElement) {
|
||||
const theme = createTheme();
|
||||
return render(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<MantineProvider theme={theme}>{children}</MantineProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe('UnifiedTitle', () => {
|
||||
it('should render title with correct children', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle>Test Title</UnifiedTitle>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with default order 1', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle>Heading 1</UnifiedTitle>
|
||||
);
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading).toHaveTextContent('Heading 1');
|
||||
});
|
||||
|
||||
it('should render with custom order', () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<UnifiedTitle order={2}>Heading 2</UnifiedTitle>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<MantineProvider theme={createTheme()}>
|
||||
<UnifiedTitle order={3}>Heading 3</UnifiedTitle>
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom alignment', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle align="center">Centered Title</UnifiedTitle>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Centered Title');
|
||||
expect(title).toHaveStyle('text-align: center');
|
||||
});
|
||||
|
||||
it('should render with primary color by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle>Default Color</UnifiedTitle>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Default Color')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with secondary color', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle color="secondary">Secondary Color</UnifiedTitle>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Secondary Color')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with brand color', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle color="brand">Brand Color</UnifiedTitle>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Brand Color')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept custom margin props', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle mb="lg" mt="xl">With Margins</UnifiedTitle>
|
||||
);
|
||||
|
||||
const title = screen.getByText('With Margins');
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept custom style prop', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle style={{ fontWeight: 900 }}>Custom Style</UnifiedTitle>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Custom Style');
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with order 4', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle order={4}>Heading 4</UnifiedTitle>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 4 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with order 5', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle order={5}>Heading 5</UnifiedTitle>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 5 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with order 6', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedTitle order={6}>Heading 6</UnifiedTitle>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 6 })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnifiedText', () => {
|
||||
it('should render text with correct children', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText>Test Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with body size by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText>Body Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Body Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with small size', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText size="small">Small Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Small Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with label size', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText size="label">Label Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Label Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with normal weight by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText>Normal Weight</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Normal Weight')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with medium weight', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText weight="medium">Medium Weight</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Medium Weight')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with bold weight', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText weight="bold">Bold Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Bold Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom alignment', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText align="right">Right Aligned</UnifiedText>
|
||||
);
|
||||
|
||||
const text = screen.getByText('Right Aligned');
|
||||
expect(text).toHaveStyle('text-align: right');
|
||||
});
|
||||
|
||||
it('should render with primary color by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText>Primary Color</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Primary Color')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with secondary color', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText color="secondary">Secondary Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Secondary Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with tertiary color', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText color="tertiary">Tertiary Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Tertiary Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with muted color', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText color="muted">Muted Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Muted Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with brand color', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText color="brand">Brand Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Brand Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with link color', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText color="link">Link Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Link Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render as span when span prop is true', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText span>Span Text</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Span Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept custom margin props', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText mb="sm" mt="md">With Margins</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('With Margins')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept custom style prop', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedText style={{ textDecoration: 'underline' }}>Custom Style</UnifiedText>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom Style')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnifiedPageHeader', () => {
|
||||
it('should render with title', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedPageHeader title="Page Title" />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Page Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with optional subtitle', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedPageHeader title="Page Title" subtitle="Page Subtitle" />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Page Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Page Subtitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without subtitle when not provided', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedPageHeader title="Page Title" />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Page Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with action', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedPageHeader
|
||||
title="Page Title"
|
||||
action={<button>Action Button</button>}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Page Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Action Button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show border by default', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedPageHeader title="Page Title" />
|
||||
);
|
||||
|
||||
// The border is applied via style, checking if component renders
|
||||
expect(screen.getByText('Page Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide border when showBorder is false', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedPageHeader title="Page Title" showBorder={false} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Page Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom style', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedPageHeader
|
||||
title="Page Title"
|
||||
style={{ backgroundColor: 'red' }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Page Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render title as order 3 heading', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedPageHeader title="Page Title" />
|
||||
);
|
||||
|
||||
// The title should be rendered with UnifiedTitle order={3}
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render subtitle with small size and secondary color', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedPageHeader title="Page Title" subtitle="Page Subtitle" />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Page Subtitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept additional Mantine Box props', () => {
|
||||
renderWithMantine(
|
||||
<UnifiedPageHeader title="Page Title" mb="xl" />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Page Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* Admin Authentication E2E Tests
|
||||
*
|
||||
* End-to-end tests for admin login and authentication flow
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Admin Authentication', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Go to admin login page before each test
|
||||
await page.goto('/admin/login');
|
||||
});
|
||||
|
||||
test('should display login page with correct elements', async ({ page }) => {
|
||||
// Check for page title
|
||||
await expect(page).toHaveTitle(/Admin/);
|
||||
|
||||
// Check for login form elements
|
||||
await expect(page.getByPlaceholder('Nomor WhatsApp')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Kirim OTP/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show validation error for empty phone number', async ({ page }) => {
|
||||
// Try to submit without entering phone number
|
||||
await page.getByRole('button', { name: /Kirim OTP/i }).click();
|
||||
|
||||
// Should show validation error
|
||||
await expect(
|
||||
page.getByText(/nomor telepon/i).or(page.getByText(/wajib diisi/i))
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show validation error for short phone number', async ({ page }) => {
|
||||
// Enter invalid phone number (less than 10 digits)
|
||||
await page.getByPlaceholder('Nomor WhatsApp').fill('0812345');
|
||||
await page.getByRole('button', { name: /Kirim OTP/i }).click();
|
||||
|
||||
// Should show validation error
|
||||
await expect(
|
||||
page.getByText(/minimal 10 digit/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show validation error for non-numeric phone number', async ({ page }) => {
|
||||
// Enter phone number with letters
|
||||
await page.getByPlaceholder('Nomor WhatsApp').fill('0812345678a');
|
||||
await page.getByRole('button', { name: /Kirim OTP/i }).click();
|
||||
|
||||
// Should show validation error
|
||||
await expect(
|
||||
page.getByText(/harus berupa angka/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should proceed to OTP verification with valid phone number', async ({ page }) => {
|
||||
// Enter valid phone number
|
||||
await page.getByPlaceholder('Nomor WhatsApp').fill('08123456789');
|
||||
await page.getByRole('button', { name: /Kirim OTP/i }).click();
|
||||
|
||||
// Should show OTP verification form
|
||||
await expect(
|
||||
page.getByPlaceholder('Kode OTP').or(page.getByLabel(/OTP/i))
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show verify button
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Verifikasi/i })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show error for invalid OTP', async ({ page }) => {
|
||||
// Enter valid phone number
|
||||
await page.getByPlaceholder('Nomor WhatsApp').fill('08123456789');
|
||||
await page.getByRole('button', { name: /Kirim OTP/i }).click();
|
||||
|
||||
// Wait for OTP form
|
||||
await page.waitForSelector('input[name="otp"], input[placeholder*="OTP"]', { timeout: 10000 });
|
||||
|
||||
// Enter invalid OTP (wrong length)
|
||||
const otpInput = page.locator('input[name="otp"], input[placeholder*="OTP"]').first();
|
||||
await otpInput.fill('12345');
|
||||
await page.getByRole('button', { name: /Verifikasi/i }).click();
|
||||
|
||||
// Should show validation error
|
||||
await expect(
|
||||
page.getByText(/harus 6 digit/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show error for non-numeric OTP', async ({ page }) => {
|
||||
// Enter valid phone number
|
||||
await page.getByPlaceholder('Nomor WhatsApp').fill('08123456789');
|
||||
await page.getByRole('button', { name: /Kirim OTP/i }).click();
|
||||
|
||||
// Wait for OTP form
|
||||
await page.waitForSelector('input[name="otp"], input[placeholder*="OTP"]', { timeout: 10000 });
|
||||
|
||||
// Enter OTP with letters
|
||||
const otpInput = page.locator('input[name="otp"], input[placeholder*="OTP"]').first();
|
||||
await otpInput.fill('12345a');
|
||||
await page.getByRole('button', { name: /Verifikasi/i }).click();
|
||||
|
||||
// Should show validation error
|
||||
await expect(
|
||||
page.getByText(/harus berupa angka/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should redirect to admin dashboard after successful login', async ({ page }) => {
|
||||
// This test requires a working backend with valid credentials
|
||||
// Skip in CI environment or use mock credentials
|
||||
|
||||
test.skip(
|
||||
process.env.CI === 'true',
|
||||
'Skip login test in CI - requires valid OTP'
|
||||
);
|
||||
|
||||
// Enter valid phone number (use test account)
|
||||
await page.getByPlaceholder('Nomor WhatsApp').fill(process.env.TEST_ADMIN_PHONE || '08123456789');
|
||||
await page.getByRole('button', { name: /Kirim OTP/i }).click();
|
||||
|
||||
// Wait for OTP form
|
||||
await page.waitForSelector('input[name="otp"]', { timeout: 10000 });
|
||||
|
||||
// In a real scenario, you would enter the OTP received
|
||||
// For testing, we'll check if the form is ready
|
||||
await expect(page.locator('input[name="otp"]')).toBeVisible();
|
||||
|
||||
// Note: Full login test requires actual OTP from WhatsApp
|
||||
// This would typically be handled with test credentials or mocked OTP
|
||||
});
|
||||
|
||||
test('should have link to return to home page', async ({ page }) => {
|
||||
// Check for home/back link
|
||||
const homeLink = page.locator('a[href="/"], a[href="/darmasaba"]');
|
||||
await expect(homeLink).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have responsive layout on mobile', async ({ page }) => {
|
||||
// Set viewport to mobile size
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Check that login form is visible
|
||||
await expect(page.getByPlaceholder('Nomor WhatsApp')).toBeVisible();
|
||||
|
||||
// Check that button is clickable
|
||||
await expect(page.getByRole('button', { name: /Kirim OTP/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin Session', () => {
|
||||
test('should redirect to dashboard if already logged in', async ({ page }) => {
|
||||
// This test requires authentication state
|
||||
// Would typically use authenticated cookies or storage state
|
||||
|
||||
test.skip(true, 'Requires authenticated session setup');
|
||||
|
||||
// Set authenticated state
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: 'desa-session',
|
||||
value: 'test-session-token',
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto('/admin/login');
|
||||
|
||||
// Should redirect to dashboard
|
||||
await expect(page).toHaveURL(/\/admin\/dashboard/);
|
||||
});
|
||||
|
||||
test('should logout successfully', async ({ page }) => {
|
||||
// This test requires an authenticated session
|
||||
test.skip(true, 'Requires authenticated session setup');
|
||||
|
||||
// Go to admin page with session
|
||||
await page.goto('/admin/dashboard');
|
||||
|
||||
// Click logout button
|
||||
await page.getByRole('button', { name: /Keluar/i }).click();
|
||||
|
||||
// Should redirect to login page
|
||||
await expect(page).toHaveURL(/\/admin\/login/);
|
||||
});
|
||||
|
||||
test('should prevent access to admin pages without authentication', async ({ page }) => {
|
||||
// Try to access admin dashboard without login
|
||||
await page.goto('/admin/dashboard');
|
||||
|
||||
// Should redirect to login page
|
||||
await expect(page).toHaveURL(/\/admin\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin Navigation', () => {
|
||||
test('should navigate to different admin sections', async ({ page }) => {
|
||||
test.skip(true, 'Requires authenticated session setup');
|
||||
|
||||
// Login first (would need proper authentication)
|
||||
await page.goto('/admin/login');
|
||||
// ... login steps
|
||||
|
||||
// Navigate to berita section
|
||||
await page.getByRole('link', { name: /Berita/i }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/desa\/berita/);
|
||||
|
||||
// Navigate to profile section
|
||||
await page.getByRole('link', { name: /Profil/i }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/desa\/profile/);
|
||||
});
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
/**
|
||||
* Public Pages E2E Tests
|
||||
*
|
||||
* End-to-end tests for public-facing darmasaba pages
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Homepage', () => {
|
||||
test('should redirect to /darmasaba from root', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Should redirect to /darmasaba
|
||||
await page.waitForURL('/darmasaba');
|
||||
await expect(page).toHaveURL('/darmasaba');
|
||||
});
|
||||
|
||||
test('should display main heading DARMASABA', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Check for main heading
|
||||
await expect(page.getByText('DARMASABA', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have responsive layout on mobile', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Set viewport to mobile size
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Main content should be visible
|
||||
await expect(page.getByText('DARMASABA')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have proper meta title', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Check page title contains Darmasaba
|
||||
await expect(page).toHaveTitle(/Darmasaba/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
test('should have navigation menu', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Check for navigation elements
|
||||
const nav = page.locator('nav');
|
||||
await expect(nav).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to PPID section', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Find and click PPID link
|
||||
const ppidLink = page.locator('a[href*="ppid"]').first();
|
||||
await expect(ppidLink).toBeVisible();
|
||||
await ppidLink.click();
|
||||
|
||||
// Should navigate to PPID page
|
||||
await expect(page).toHaveURL(/ppid/);
|
||||
});
|
||||
|
||||
test('should navigate to health section', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Find and click health link
|
||||
const healthLink = page.locator('a[href*="kesehatan"]').first();
|
||||
await expect(healthLink).toBeVisible();
|
||||
await healthLink.click();
|
||||
|
||||
// Should navigate to health page
|
||||
await expect(page).toHaveURL(/kesehatan/);
|
||||
});
|
||||
|
||||
test('should navigate to education section', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Find and click education link
|
||||
const educationLink = page.locator('a[href*="pendidikan"]').first();
|
||||
await expect(educationLink).toBeVisible();
|
||||
await educationLink.click();
|
||||
|
||||
// Should navigate to education page
|
||||
await expect(page).toHaveURL(/pendidikan/);
|
||||
});
|
||||
|
||||
test('should navigate to economy section', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Find and click economy link
|
||||
const economyLink = page.locator('a[href*="ekonomi"]').first();
|
||||
await expect(economyLink).toBeVisible();
|
||||
await economyLink.click();
|
||||
|
||||
// Should navigate to economy page
|
||||
await expect(page).toHaveURL(/ekonomi/);
|
||||
});
|
||||
|
||||
test('should navigate to environment section', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Find and click environment link
|
||||
const envLink = page.locator('a[href*="lingkungan"]').first();
|
||||
await expect(envLink).toBeVisible();
|
||||
await envLink.click();
|
||||
|
||||
// Should navigate to environment page
|
||||
await expect(page).toHaveURL(/lingkungan/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PPID (Public Information)', () => {
|
||||
test('should display PPID page', async ({ page }) => {
|
||||
await page.goto('/darmasaba/ppid');
|
||||
|
||||
// Check for PPID heading
|
||||
await expect(page.getByText(/PPID|Informasi Publik/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display information categories', async ({ page }) => {
|
||||
await page.goto('/darmasaba/ppid');
|
||||
|
||||
// Should have information categories
|
||||
await expect(page.locator('text=Kategori')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('News/Berita Section', () => {
|
||||
test('should display news list page', async ({ page }) => {
|
||||
await page.goto('/darmasaba/berita');
|
||||
|
||||
// Check for news heading
|
||||
await expect(page.getByText(/Berita|Kabar Desa/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display news articles', async ({ page }) => {
|
||||
await page.goto('/darmasaba/berita');
|
||||
|
||||
// Should have news articles or empty state
|
||||
const articles = page.locator('[class*="berita"], [class*="news"], article');
|
||||
await expect(articles).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to news detail page', async ({ page }) => {
|
||||
await page.goto('/darmasaba/berita');
|
||||
|
||||
// Find and click first news article
|
||||
const firstArticle = page.locator('a[href*="berita"]').first();
|
||||
await expect(firstArticle).toBeVisible();
|
||||
await firstArticle.click();
|
||||
|
||||
// Should navigate to detail page
|
||||
await expect(page).toHaveURL(/berita\/(?!list)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Security/Kamtrantibmas Section', () => {
|
||||
test('should display security page', async ({ page }) => {
|
||||
await page.goto('/darmasaba/kamtrantibmas');
|
||||
|
||||
// Check for security heading
|
||||
await expect(page.getByText(/Kamtrantibmas|Keamanan/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Culture/Budaya Section', () => {
|
||||
test('should display culture page', async ({ page }) => {
|
||||
await page.goto('/darmasaba/budaya');
|
||||
|
||||
// Check for culture heading
|
||||
await expect(page.getByText(/Budaya|Kebudayaan/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Innovation Section', () => {
|
||||
test('should display innovation page', async ({ page }) => {
|
||||
await page.goto('/darmasaba/inovasi');
|
||||
|
||||
// Check for innovation heading
|
||||
await expect(page.getByText(/Inovasi|Innovation/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Footer', () => {
|
||||
test('should have footer with contact information', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Check for footer
|
||||
const footer = page.locator('footer');
|
||||
await expect(footer).toBeVisible();
|
||||
|
||||
// Should have contact info
|
||||
await expect(
|
||||
page.getByText(/Kontak|Hubungi|Alamat/i).or(page.locator('footer'))
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have social media links', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Check for social media links in footer
|
||||
const socialLinks = page.locator('footer a[href*="facebook"], footer a[href*="instagram"], footer a[href*="twitter"]');
|
||||
await expect(socialLinks).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have copyright information', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Check for copyright
|
||||
await expect(
|
||||
page.getByText(/©|Copyright|Hak Cipta/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Search Functionality', () => {
|
||||
test('should have search feature', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Check for search input or button
|
||||
const searchInput = page.locator('input[type="search"], input[placeholder*="Cari"]');
|
||||
await expect(searchInput).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display search results', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Find search input
|
||||
const searchInput = page.locator('input[type="search"], input[placeholder*="Cari"]').first();
|
||||
await searchInput.fill('test');
|
||||
|
||||
// Submit search
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Should show search results page or results
|
||||
await expect(page).toHaveURL(/search|cari/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('should have proper heading hierarchy', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Should have h1
|
||||
const h1 = page.locator('h1');
|
||||
await expect(h1).toBeVisible();
|
||||
|
||||
// Should have only one h1
|
||||
const h1Count = await h1.count();
|
||||
expect(h1Count).toBe(1);
|
||||
});
|
||||
|
||||
test('should have alt text for images', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// All images should have alt text
|
||||
const images = page.locator('img');
|
||||
const count = await images.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const alt = await images.nth(i).getAttribute('alt');
|
||||
// Alt can be empty string for decorative images, but attribute should exist
|
||||
expect(alt !== null).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('should have skip link for accessibility', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Check for skip link (common accessibility feature)
|
||||
const skipLink = page.locator('a[href="#main-content"], a[href="#content"]');
|
||||
// This is optional but recommended
|
||||
// await expect(skipLink).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be keyboard navigable', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Tab through interactive elements
|
||||
await page.keyboard.press('Tab');
|
||||
let focusedElement = await page.evaluate(() => document.activeElement?.tagName);
|
||||
expect(['A', 'BUTTON', 'INPUT']).toContain(focusedElement);
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
focusedElement = await page.evaluate(() => document.activeElement?.tagName);
|
||||
expect(['A', 'BUTTON', 'INPUT']).toContain(focusedElement);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Performance', () => {
|
||||
test('should load within acceptable time', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
await page.goto('/darmasaba');
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
// Should load within 5 seconds (adjust based on requirements)
|
||||
expect(loadTime).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
test('should not have layout shift', async ({ page }) => {
|
||||
await page.goto('/darmasaba');
|
||||
|
||||
// Wait for page to stabilize
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Get initial viewport height
|
||||
const initialHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||
|
||||
// Wait a bit more
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check if height changed significantly
|
||||
const finalHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||
|
||||
// Allow small variations but not large layout shifts
|
||||
expect(Math.abs(finalHeight - initialHeight)).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Error Handling', () => {
|
||||
test('should handle 404 pages gracefully', async ({ page }) => {
|
||||
await page.goto('/darmasaba/nonexistent-page-12345');
|
||||
|
||||
// Should show 404 page or redirect
|
||||
await expect(page).toHaveURL(/404|darmasaba/);
|
||||
});
|
||||
|
||||
test('should have proper error page content', async ({ page }) => {
|
||||
await page.goto('/darmasaba/nonexistent-page-12345');
|
||||
|
||||
// Wait for potential redirect
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should show error message or redirect to valid page
|
||||
const content = await page.content();
|
||||
expect(
|
||||
content.includes('404') ||
|
||||
content.includes('Tidak ditemukan') ||
|
||||
content.includes('DARMASABA')
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,332 +0,0 @@
|
||||
/**
|
||||
* Sanitizer Utilities Unit Tests
|
||||
*
|
||||
* Tests for HTML/text sanitization functions in lib/sanitizer
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
sanitizeHtml,
|
||||
sanitizeText,
|
||||
sanitizeUrl,
|
||||
sanitizeYouTubeUrl,
|
||||
} from '@/lib/sanitizer';
|
||||
|
||||
// ============================================================================
|
||||
// sanitizeHtml Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('sanitizeHtml', () => {
|
||||
it('should return empty string for null/undefined input', () => {
|
||||
expect(sanitizeHtml(null as any)).toBe('');
|
||||
expect(sanitizeHtml(undefined as any)).toBe('');
|
||||
expect(sanitizeHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('should return clean HTML unchanged', () => {
|
||||
const input = '<p>This is a <strong>clean</strong> paragraph.</p>';
|
||||
expect(sanitizeHtml(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should remove script tags', () => {
|
||||
const input = '<p>Safe</p><script>alert("XSS")</script><p>Safe</p>';
|
||||
const expected = '<p>Safe</p><p>Safe</p>';
|
||||
expect(sanitizeHtml(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should remove script tags with attributes', () => {
|
||||
const input = '<script type="text/javascript">alert("XSS")</script>';
|
||||
expect(sanitizeHtml(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should remove javascript: protocol in href', () => {
|
||||
const input = '<a href="javascript:alert(\'XSS\')">Click me</a>';
|
||||
const result = sanitizeHtml(input);
|
||||
// Should replace javascript: with empty string
|
||||
expect(result).not.toContain('javascript:');
|
||||
expect(result).toContain('<a href=');
|
||||
});
|
||||
|
||||
it('should remove javascript: protocol in src', () => {
|
||||
const input = '<img src="javascript:alert(\'XSS\')" />';
|
||||
const result = sanitizeHtml(input);
|
||||
// Should replace javascript: with empty string
|
||||
expect(result).not.toContain('javascript:');
|
||||
expect(result).toContain('<img src=');
|
||||
});
|
||||
|
||||
it('should remove onclick handlers', () => {
|
||||
const input = '<button onclick="alert(\'XSS\')">Click</button>';
|
||||
const result = sanitizeHtml(input);
|
||||
// Should remove onclick attribute
|
||||
expect(result).not.toContain('onclick');
|
||||
expect(result).toContain('<button');
|
||||
expect(result).toContain('Click</button>');
|
||||
});
|
||||
|
||||
it('should remove onerror handlers', () => {
|
||||
const input = '<img src="x" onerror="alert(\'XSS\')" />';
|
||||
const result = sanitizeHtml(input);
|
||||
// Should remove onerror attribute
|
||||
expect(result).not.toContain('onerror');
|
||||
expect(result).toContain('<img');
|
||||
});
|
||||
|
||||
it('should remove onload handlers', () => {
|
||||
const input = '<body onload="alert(\'XSS\')">';
|
||||
const result = sanitizeHtml(input);
|
||||
// Should remove onload attribute (regex may leave partial content)
|
||||
expect(result).not.toContain('onload');
|
||||
expect(result).toContain('<body');
|
||||
});
|
||||
|
||||
it('should remove iframe tags', () => {
|
||||
const input = '<p>Before</p><iframe src="https://evil.com"></iframe><p>After</p>';
|
||||
const expected = '<p>Before</p><p>After</p>';
|
||||
expect(sanitizeHtml(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should remove object tags', () => {
|
||||
const input = '<object data="evil.swf"></object>';
|
||||
expect(sanitizeHtml(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should remove embed tags', () => {
|
||||
const input = '<embed src="evil.swf" />';
|
||||
const result = sanitizeHtml(input);
|
||||
// Note: embed regex may not fully remove the tag in all cases
|
||||
// This is a known limitation - embed should be sanitized server-side
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should remove data: protocol in src', () => {
|
||||
const input = '<img src="data:image/svg+xml,<svg onload=\'alert(1)\'>" />';
|
||||
const result = sanitizeHtml(input);
|
||||
// Should replace data: with empty string
|
||||
expect(result).not.toContain('data:');
|
||||
expect(result).toContain('<img src=');
|
||||
});
|
||||
|
||||
it('should remove expression() in CSS', () => {
|
||||
const input = '<div style="width: expression(alert(\'XSS\'))">Content</div>';
|
||||
const result = sanitizeHtml(input);
|
||||
// Should remove expression() but may leave parentheses
|
||||
expect(result).not.toContain('expression');
|
||||
expect(result).toContain('<div style=');
|
||||
expect(result).toContain('Content</div>');
|
||||
});
|
||||
|
||||
it('should handle multiple XSS vectors', () => {
|
||||
const input = `
|
||||
<div onclick="alert(1)">
|
||||
<script>alert(2)</script>
|
||||
<a href="javascript:alert(3)">Link</a>
|
||||
<img src="x" onerror="alert(4)" />
|
||||
</div>
|
||||
`;
|
||||
const sanitized = sanitizeHtml(input);
|
||||
expect(sanitized).not.toContain('<script>');
|
||||
expect(sanitized).not.toContain('javascript:');
|
||||
expect(sanitized).not.toContain('onclick');
|
||||
expect(sanitized).not.toContain('onerror');
|
||||
});
|
||||
|
||||
it('should preserve safe HTML formatting', () => {
|
||||
const input = `
|
||||
<article>
|
||||
<h1>Article Title</h1>
|
||||
<p>Paragraph with <strong>bold</strong> and <em>italic</em>.</p>
|
||||
<ul>
|
||||
<li>List item 1</li>
|
||||
<li>List item 2</li>
|
||||
</ul>
|
||||
</article>
|
||||
`;
|
||||
expect(sanitizeHtml(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle nested dangerous elements', () => {
|
||||
const input = '<div><script><img src=x onerror=alert(1)></script></div>';
|
||||
const expected = '<div></div>';
|
||||
expect(sanitizeHtml(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// sanitizeText Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('sanitizeText', () => {
|
||||
it('should return empty string for null/undefined input', () => {
|
||||
expect(sanitizeText(null as any)).toBe('');
|
||||
expect(sanitizeText(undefined as any)).toBe('');
|
||||
expect(sanitizeText('')).toBe('');
|
||||
});
|
||||
|
||||
it('should remove all HTML tags', () => {
|
||||
const input = '<p>This is <strong>bold</strong> text</p>';
|
||||
const expected = 'This is bold text';
|
||||
expect(sanitizeText(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should remove script tags completely', () => {
|
||||
const input = 'Hello <script>alert("XSS")</script> World';
|
||||
const result = sanitizeText(input);
|
||||
// sanitizeText removes HTML tags but keeps text content
|
||||
// Note: This is expected behavior - sanitizeText is for plain text extraction
|
||||
// For security, use sanitizeHtml first for HTML content
|
||||
expect(result).toContain('Hello');
|
||||
expect(result).toContain('World');
|
||||
expect(result).not.toContain('<script>');
|
||||
// alert text remains since sanitizeText only removes tags, not content
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const input = ' <p> trimmed </p> ';
|
||||
const expected = 'trimmed';
|
||||
expect(sanitizeText(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle plain text unchanged', () => {
|
||||
const input = 'This is plain text without any HTML tags';
|
||||
expect(sanitizeText(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle complex HTML structures', () => {
|
||||
const input = `
|
||||
<div>
|
||||
<h1>Title</h1>
|
||||
<p>Paragraph with <a href="#">link</a></p>
|
||||
<ul><li>Item</li></ul>
|
||||
</div>
|
||||
`;
|
||||
const expected = 'Title Paragraph with link Item';
|
||||
expect(sanitizeText(input)).toContain('Title');
|
||||
expect(sanitizeText(input)).toContain('Paragraph');
|
||||
expect(sanitizeText(input)).toContain('link');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// sanitizeUrl Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('sanitizeUrl', () => {
|
||||
it('should return empty string for null/undefined input', () => {
|
||||
expect(sanitizeUrl(null as any)).toBe('');
|
||||
expect(sanitizeUrl(undefined as any)).toBe('');
|
||||
expect(sanitizeUrl('')).toBe('');
|
||||
});
|
||||
|
||||
it('should accept valid HTTP URLs', () => {
|
||||
const input = 'http://example.com';
|
||||
const result = sanitizeUrl(input);
|
||||
// URL constructor adds trailing slash
|
||||
expect(result).toMatch(/^http:\/\/example\.com/);
|
||||
});
|
||||
|
||||
it('should accept valid HTTPS URLs', () => {
|
||||
const input = 'https://example.com/path?query=value';
|
||||
expect(sanitizeUrl(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should reject javascript: protocol', () => {
|
||||
const input = 'javascript:alert("XSS")';
|
||||
expect(sanitizeUrl(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should reject data: protocol', () => {
|
||||
const input = 'data:text/html,<script>alert("XSS")</script>';
|
||||
expect(sanitizeUrl(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should reject vbscript: protocol', () => {
|
||||
const input = 'vbscript:msgbox("XSS")';
|
||||
expect(sanitizeUrl(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should reject file: protocol', () => {
|
||||
const input = 'file:///etc/passwd';
|
||||
expect(sanitizeUrl(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle invalid URLs', () => {
|
||||
expect(sanitizeUrl('not-a-url')).toBe('');
|
||||
expect(sanitizeUrl('://missing-protocol')).toBe('');
|
||||
expect(sanitizeUrl('http://')).toBe('');
|
||||
});
|
||||
|
||||
it('should preserve URL parameters', () => {
|
||||
const input = 'https://example.com/path?param1=value1¶m2=value2#hash';
|
||||
expect(sanitizeUrl(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle URLs with ports', () => {
|
||||
const input = 'https://localhost:3000/api/test';
|
||||
expect(sanitizeUrl(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// sanitizeYouTubeUrl Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('sanitizeYouTubeUrl', () => {
|
||||
it('should return empty string for null/undefined input', () => {
|
||||
expect(sanitizeYouTubeUrl(null as any)).toBe('');
|
||||
expect(sanitizeYouTubeUrl(undefined as any)).toBe('');
|
||||
expect(sanitizeYouTubeUrl('')).toBe('');
|
||||
});
|
||||
|
||||
it('should accept standard YouTube URL', () => {
|
||||
const input = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
expect(sanitizeYouTubeUrl(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should accept YouTube short URL', () => {
|
||||
const input = 'https://youtu.be/dQw4w9WgXcQ';
|
||||
const expected = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
expect(sanitizeYouTubeUrl(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should accept YouTube URL with additional parameters', () => {
|
||||
const input = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=10s';
|
||||
const expected = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
expect(sanitizeYouTubeUrl(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should accept YouTube music URL', () => {
|
||||
const input = 'https://music.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
const expected = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
expect(sanitizeYouTubeUrl(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should reject non-YouTube URLs', () => {
|
||||
expect(sanitizeYouTubeUrl('https://vimeo.com/123456')).toBe('');
|
||||
expect(sanitizeYouTubeUrl('https://example.com')).toBe('');
|
||||
expect(sanitizeYouTubeUrl('https://dailymotion.com/video/123')).toBe('');
|
||||
});
|
||||
|
||||
it('should reject YouTube URLs with invalid video ID', () => {
|
||||
// YouTube video IDs are exactly 11 characters
|
||||
expect(sanitizeYouTubeUrl('https://www.youtube.com/watch?v=tooshort')).toBe('');
|
||||
expect(sanitizeYouTubeUrl('https://www.youtube.com/watch?v=waytoolongvideoid')).toBe('');
|
||||
});
|
||||
|
||||
it('should reject invalid URLs', () => {
|
||||
expect(sanitizeYouTubeUrl('not-a-url')).toBe('');
|
||||
expect(sanitizeYouTubeUrl('youtube.com')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle YouTube URLs with www vs non-www', () => {
|
||||
const input1 = 'https://youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
const expected = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
expect(sanitizeYouTubeUrl(input1)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle HTTPS vs HTTP YouTube URLs', () => {
|
||||
const input = 'http://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
const expected = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
expect(sanitizeYouTubeUrl(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,555 +0,0 @@
|
||||
/**
|
||||
* Validation Schemas Unit Tests
|
||||
*
|
||||
* Tests for Zod validation schemas in lib/validations
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createBeritaSchema,
|
||||
updateBeritaSchema,
|
||||
loginRequestSchema,
|
||||
otpVerificationSchema,
|
||||
uploadFileSchema,
|
||||
registerUserSchema,
|
||||
paginationSchema,
|
||||
} from '@/lib/validations';
|
||||
|
||||
// ============================================================================
|
||||
// Berita Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('createBeritaSchema', () => {
|
||||
const validData = {
|
||||
judul: 'Judul Berita Valid',
|
||||
deskripsi: 'Deskripsi yang cukup panjang untuk berita',
|
||||
content: 'Konten berita yang lengkap dengan minimal 50 karakter',
|
||||
kategoriBeritaId: 'clm5z8z8z000008l4f3qz8z8z',
|
||||
imageId: 'clm5z8z8z000008l4f3qz8z8z',
|
||||
};
|
||||
|
||||
it('should accept valid berita data', () => {
|
||||
const result = createBeritaSchema.safeParse(validData);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject short titles (less than 5 characters)', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
judul: 'abc',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].path).toContain('judul');
|
||||
expect(result.error.errors[0].message).toContain('minimal 5 karakter');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject long titles (more than 255 characters)', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
judul: 'a'.repeat(256),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].path).toContain('judul');
|
||||
expect(result.error.errors[0].message).toContain('maksimal 255 karakter');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject short descriptions (less than 10 characters)', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
deskripsi: 'short',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].path).toContain('deskripsi');
|
||||
expect(result.error.errors[0].message).toContain('minimal 10 karakter');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject long descriptions (more than 500 characters)', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
deskripsi: 'a'.repeat(501),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].path).toContain('deskripsi');
|
||||
expect(result.error.errors[0].message).toContain('maksimal 500 karakter');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject short content (less than 50 characters)', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
content: 'short',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].path).toContain('content');
|
||||
expect(result.error.errors[0].message).toContain('minimal 50 karakter');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid cuid for kategoriBeritaId', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
kategoriBeritaId: 'invalid-id',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].path).toContain('kategoriBeritaId');
|
||||
expect(result.error.errors[0].message).toContain('tidak valid');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid cuid for imageId', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
imageId: 'invalid-id',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].path).toContain('imageId');
|
||||
expect(result.error.errors[0].message).toContain('tidak valid');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid YouTube URL for linkVideo', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
linkVideo: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid URL for linkVideo', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
linkVideo: 'not-a-url',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].path).toContain('linkVideo');
|
||||
expect(result.error.errors[0].message).toContain('tidak valid');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept empty string for linkVideo', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
linkVideo: '',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept optional imageIds array with valid cuids', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
imageIds: ['clm5z8z8z000008l4f3qz8z8z', 'clm5z8z8z000008l4f3qz8z8y'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject imageIds array with invalid cuid', () => {
|
||||
const result = createBeritaSchema.safeParse({
|
||||
...validData,
|
||||
imageIds: ['invalid-id'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBeritaSchema', () => {
|
||||
it('should accept partial data for updates', () => {
|
||||
const result = updateBeritaSchema.safeParse({
|
||||
judul: 'Updated Title',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept empty object', () => {
|
||||
const result = updateBeritaSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should still validate provided fields', () => {
|
||||
const result = updateBeritaSchema.safeParse({
|
||||
judul: 'abc', // too short
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// OTP/Login Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('loginRequestSchema', () => {
|
||||
it('should accept valid phone number', () => {
|
||||
const result = loginRequestSchema.safeParse({
|
||||
nomor: '08123456789',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject phone number with less than 10 digits', () => {
|
||||
const result = loginRequestSchema.safeParse({
|
||||
nomor: '08123456',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('minimal 10 digit');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject phone number with more than 15 digits', () => {
|
||||
const result = loginRequestSchema.safeParse({
|
||||
nomor: '081234567890123456',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('maksimal 15 digit');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject phone number with non-numeric characters', () => {
|
||||
const result = loginRequestSchema.safeParse({
|
||||
nomor: '0812-3456-789',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('harus berupa angka');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject empty phone number', () => {
|
||||
const result = loginRequestSchema.safeParse({
|
||||
nomor: '',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('otpVerificationSchema', () => {
|
||||
it('should accept valid OTP verification data', () => {
|
||||
const result = otpVerificationSchema.safeParse({
|
||||
nomor: '08123456789',
|
||||
kodeId: 'clm5z8z8z000008l4f3qz8z8z',
|
||||
otp: '123456',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject OTP with wrong length', () => {
|
||||
const result = otpVerificationSchema.safeParse({
|
||||
nomor: '08123456789',
|
||||
kodeId: 'clm5z8z8z000008l4f3qz8z8z',
|
||||
otp: '12345',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('harus 6 digit');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject OTP with non-numeric characters', () => {
|
||||
const result = otpVerificationSchema.safeParse({
|
||||
nomor: '08123456789',
|
||||
kodeId: 'clm5z8z8z000008l4f3qz8z8z',
|
||||
otp: '12345a',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('harus berupa angka');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid kodeId', () => {
|
||||
const result = otpVerificationSchema.safeParse({
|
||||
nomor: '08123456789',
|
||||
kodeId: 'invalid-id',
|
||||
otp: '123456',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('tidak valid');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// File Upload Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('uploadFileSchema', () => {
|
||||
it('should accept valid file upload data', () => {
|
||||
const result = uploadFileSchema.safeParse({
|
||||
name: 'document.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 1024 * 1024, // 1MB
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty file name', () => {
|
||||
const result = uploadFileSchema.safeParse({
|
||||
name: '',
|
||||
type: 'application/pdf',
|
||||
size: 1024 * 1024,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('wajib diisi');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept allowed image types', () => {
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
allowedTypes.forEach((type) => {
|
||||
const result = uploadFileSchema.safeParse({
|
||||
name: 'file.jpg',
|
||||
type,
|
||||
size: 1024 * 1024,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept allowed document types', () => {
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
|
||||
allowedTypes.forEach((type) => {
|
||||
const result = uploadFileSchema.safeParse({
|
||||
name: 'document.doc',
|
||||
type,
|
||||
size: 1024 * 1024,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject disallowed file types', () => {
|
||||
const result = uploadFileSchema.safeParse({
|
||||
name: 'file.exe',
|
||||
type: 'application/x-executable',
|
||||
size: 1024 * 1024,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('tidak diizinkan');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject files larger than 5MB', () => {
|
||||
const result = uploadFileSchema.safeParse({
|
||||
name: 'largefile.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 6 * 1024 * 1024, // 6MB
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('maksimal 5MB');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept files exactly 5MB', () => {
|
||||
const result = uploadFileSchema.safeParse({
|
||||
name: 'file.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 5 * 1024 * 1024, // 5MB
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// User Registration Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('registerUserSchema', () => {
|
||||
it('should accept valid user registration data', () => {
|
||||
const result = registerUserSchema.safeParse({
|
||||
name: 'John Doe',
|
||||
nomor: '08123456789',
|
||||
email: 'john@example.com',
|
||||
roleId: 1,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject short names (less than 3 characters)', () => {
|
||||
const result = registerUserSchema.safeParse({
|
||||
name: 'Jo',
|
||||
nomor: '08123456789',
|
||||
roleId: 1,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('minimal 3 karakter');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject long names (more than 100 characters)', () => {
|
||||
const result = registerUserSchema.safeParse({
|
||||
name: 'a'.repeat(101),
|
||||
nomor: '08123456789',
|
||||
roleId: 1,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('maksimal 100 karakter');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid phone numbers', () => {
|
||||
const result = registerUserSchema.safeParse({
|
||||
name: 'John Doe',
|
||||
nomor: 'invalid',
|
||||
roleId: 1,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept empty email', () => {
|
||||
const result = registerUserSchema.safeParse({
|
||||
name: 'John Doe',
|
||||
nomor: '08123456789',
|
||||
email: '',
|
||||
roleId: 1,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid email format', () => {
|
||||
const result = registerUserSchema.safeParse({
|
||||
name: 'John Doe',
|
||||
nomor: '08123456789',
|
||||
email: 'not-an-email',
|
||||
roleId: 1,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('tidak valid');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject non-integer roleId', () => {
|
||||
const result = registerUserSchema.safeParse({
|
||||
name: 'John Doe',
|
||||
nomor: '08123456789',
|
||||
email: 'john@example.com',
|
||||
roleId: 1.5,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('angka bulat');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject non-positive roleId', () => {
|
||||
const result = registerUserSchema.safeParse({
|
||||
name: 'John Doe',
|
||||
nomor: '08123456789',
|
||||
email: 'john@example.com',
|
||||
roleId: 0,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('lebih dari 0');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Pagination Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('paginationSchema', () => {
|
||||
it('should accept default pagination values', () => {
|
||||
const result = paginationSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.page).toBe(1);
|
||||
expect(result.data.limit).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept custom page and limit', () => {
|
||||
const result = paginationSchema.safeParse({
|
||||
page: '5',
|
||||
limit: '25',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.page).toBe(5);
|
||||
expect(result.data.limit).toBe(25);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject page less than 1', () => {
|
||||
const result = paginationSchema.safeParse({
|
||||
page: '0',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('lebih dari 0');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject limit less than 1', () => {
|
||||
const result = paginationSchema.safeParse({
|
||||
limit: '0',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('antara 1-100');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject limit greater than 100', () => {
|
||||
const result = paginationSchema.safeParse({
|
||||
limit: '101',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('antara 1-100');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept limit exactly 100', () => {
|
||||
const result = paginationSchema.safeParse({
|
||||
limit: '100',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept optional search parameter', () => {
|
||||
const result = paginationSchema.safeParse({
|
||||
search: 'test query',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.search).toBe('test query');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle invalid page numbers gracefully', () => {
|
||||
const result = paginationSchema.safeParse({
|
||||
page: 'abc',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* WhatsApp Service Unit Tests
|
||||
*
|
||||
* Tests for WhatsApp OTP service in lib/whatsapp
|
||||
* Note: These tests use direct fetch mocking, not MSW
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
sendWhatsAppOTP,
|
||||
formatOTPMessage,
|
||||
formatOTPMessageWithReference,
|
||||
} from '@/lib/whatsapp';
|
||||
|
||||
describe('WhatsApp Service', () => {
|
||||
// Store original fetch
|
||||
const originalFetch = global.fetch;
|
||||
let mockFetch: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// formatOTPMessage Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('formatOTPMessage', () => {
|
||||
it('should format OTP message with numeric code', () => {
|
||||
const otpCode = 123456;
|
||||
const message = formatOTPMessage(otpCode);
|
||||
|
||||
expect(message).toContain('Website Desa Darmasaba');
|
||||
expect(message).toContain('RAHASIA');
|
||||
expect(message).toContain('JANGAN DI BAGIKAN');
|
||||
expect(message).toContain('123456');
|
||||
expect(message).toContain('satu kali login');
|
||||
});
|
||||
|
||||
it('should format OTP message with string code', () => {
|
||||
const otpCode = '654321';
|
||||
const message = formatOTPMessage(otpCode);
|
||||
|
||||
expect(message).toContain('654321');
|
||||
});
|
||||
|
||||
it('should include security warning', () => {
|
||||
const message = formatOTPMessage(123456);
|
||||
|
||||
expect(message).toMatch(/RAHASIA/);
|
||||
expect(message).toMatch(/JANGAN DI BAGIKAN KEPADA SIAPAPUN/);
|
||||
});
|
||||
|
||||
it('should mention code validity', () => {
|
||||
const message = formatOTPMessage(123456);
|
||||
|
||||
expect(message).toMatch(/hanya berlaku untuk satu kali login/);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// formatOTPMessageWithReference Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('formatOTPMessageWithReference', () => {
|
||||
it('should format message with reference ID', () => {
|
||||
const otpId = 'clm5z8z8z000008l4f3qz8z8z';
|
||||
const message = formatOTPMessageWithReference(otpId);
|
||||
|
||||
expect(message).toContain('Website Desa Darmasaba');
|
||||
expect(message).toContain('RAHASIA');
|
||||
expect(message).toContain('JANGAN DI BAGIKAN');
|
||||
expect(message).toContain(otpId);
|
||||
expect(message).toContain('Reference ID');
|
||||
});
|
||||
|
||||
it('should NOT include actual OTP code', () => {
|
||||
const message = formatOTPMessageWithReference('test-id');
|
||||
|
||||
expect(message).not.toMatch(/\d{6}/);
|
||||
});
|
||||
|
||||
it('should instruct user to enter received OTP', () => {
|
||||
const message = formatOTPMessageWithReference('test-id');
|
||||
|
||||
expect(message).toMatch(/masukkan kode OTP/);
|
||||
});
|
||||
|
||||
it('should include security warning', () => {
|
||||
const message = formatOTPMessageWithReference('test-id');
|
||||
|
||||
expect(message).toMatch(/RAHASIA/);
|
||||
expect(message).toMatch(/JANGAN DI BAGIKAN KEPADA SIAPAPUN/);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// sendWhatsAppOTP Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('sendWhatsAppOTP', () => {
|
||||
it('should send OTP successfully with valid parameters', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'success' }),
|
||||
clone: function() { return this; }
|
||||
} as any);
|
||||
|
||||
const result = await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: 'clm5z8z8z000008l4f3qz8z8z',
|
||||
message: 'Test message',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://wa.wibudev.com/send',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use POST method (not GET) for security', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'success' }),
|
||||
clone: function() { return this; }
|
||||
} as any);
|
||||
|
||||
await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: 'test-otp-id',
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0];
|
||||
expect(callArgs[0]).toBe('https://wa.wibudev.com/send');
|
||||
expect(callArgs[1]?.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('should send otpId reference, not actual OTP code', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'success' }),
|
||||
clone: function() { return this; }
|
||||
} as any);
|
||||
|
||||
await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: 'test-otp-id-123',
|
||||
message: 'Test message',
|
||||
});
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0];
|
||||
const body = JSON.parse(callArgs[1]?.body as string);
|
||||
|
||||
expect(body.otpId).toBe('test-otp-id-123');
|
||||
expect(body.nomor).toBe('08123456789');
|
||||
});
|
||||
|
||||
it('should return error for invalid phone number (empty)', async () => {
|
||||
const result = await sendWhatsAppOTP({
|
||||
nomor: '',
|
||||
otpId: 'test-id',
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toBe('Nomor telepon tidak valid');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for invalid phone number (null)', async () => {
|
||||
const result = await sendWhatsAppOTP({
|
||||
nomor: null as any,
|
||||
otpId: 'test-id',
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toBe('Nomor telepon tidak valid');
|
||||
});
|
||||
|
||||
it('should return error for invalid otpId', async () => {
|
||||
const result = await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: '',
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toBe('OTP ID tidak valid');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for null otpId', async () => {
|
||||
const result = await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: null as any,
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toBe('OTP ID tidak valid');
|
||||
});
|
||||
|
||||
it('should handle WhatsApp API error response', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: 'error',
|
||||
message: 'Invalid phone number',
|
||||
}),
|
||||
clone: function() { return this; }
|
||||
} as any);
|
||||
|
||||
const result = await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: 'test-id',
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toBe('Invalid phone number');
|
||||
});
|
||||
|
||||
it('should handle HTTP error response', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
clone: function() { return this; }
|
||||
} as any);
|
||||
|
||||
const result = await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: 'test-id',
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toBe('Gagal mengirim pesan WhatsApp');
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: 'test-id',
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toBe('Terjadi kesalahan saat mengirim pesan');
|
||||
});
|
||||
|
||||
it('should handle JSON parse errors', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new Error('Invalid JSON');
|
||||
},
|
||||
clone: function() { return this; }
|
||||
} as any);
|
||||
|
||||
const result = await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: 'test-id',
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toBe('Terjadi kesalahan saat mengirim pesan');
|
||||
});
|
||||
|
||||
it('should send correct request body structure', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'success' }),
|
||||
clone: function() { return this; }
|
||||
} as any);
|
||||
|
||||
await sendWhatsAppOTP({
|
||||
nomor: '081234567890',
|
||||
otpId: 'unique-otp-id',
|
||||
message: 'Custom message',
|
||||
});
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0];
|
||||
const body = JSON.parse(callArgs[1]?.body as string);
|
||||
|
||||
expect(body).toEqual({
|
||||
nomor: '081234567890',
|
||||
otpId: 'unique-otp-id',
|
||||
message: 'Custom message',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Security Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Security - OTP not exposed in URL', () => {
|
||||
it('should NOT include OTP code in URL query string', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'success' }),
|
||||
clone: function() { return this; }
|
||||
} as any);
|
||||
|
||||
await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: 'test-id',
|
||||
message: 'Your OTP is 123456',
|
||||
});
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0];
|
||||
const url = callArgs[0];
|
||||
|
||||
// URL should be the endpoint, not containing OTP
|
||||
expect(url).toBe('https://wa.wibudev.com/send');
|
||||
expect(url).not.toContain('123456');
|
||||
expect(url).not.toContain('?');
|
||||
});
|
||||
|
||||
it('should send OTP in request body (POST), not URL', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'success' }),
|
||||
clone: function() { return this; }
|
||||
} as any);
|
||||
|
||||
await sendWhatsAppOTP({
|
||||
nomor: '08123456789',
|
||||
otpId: 'test-id',
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
const callArgs = mockFetch.mock.calls[0];
|
||||
|
||||
// Should use POST with body
|
||||
expect(callArgs[1]?.method).toBe('POST');
|
||||
expect(callArgs[1]?.body).toBeDefined();
|
||||
|
||||
// OTP reference should be in body, not URL
|
||||
const body = JSON.parse(callArgs[1]?.body as string);
|
||||
expect(body.otpId).toBe('test-id');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,33 +2,6 @@ import '@testing-library/jest-dom';
|
||||
import { server } from './mocks/server';
|
||||
import { beforeAll, afterEach, afterAll } from 'vitest';
|
||||
|
||||
// MSW server setup for API mocking
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
// Mock window.matchMedia for Mantine components
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock IntersectionObserver for Mantine components
|
||||
global.IntersectionObserver = class IntersectionObserver {
|
||||
constructor() {}
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
unobserve() {}
|
||||
} as any;
|
||||
|
||||
49
biome.json
Normal file
49
biome.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"experimentalScannerIgnores": [
|
||||
"node_modules",
|
||||
".next",
|
||||
"out",
|
||||
"public"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn",
|
||||
"noUnusedImports": "warn"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
553
dev-inspector-click-to-source.md
Normal file
553
dev-inspector-click-to-source.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# Skill: Dev Inspector — Click-to-Source untuk Bun + Elysia + Vite + React
|
||||
|
||||
## Ringkasan
|
||||
|
||||
Fitur development: klik elemen UI di browser → langsung buka source code di editor (VS Code, Cursor, dll) pada baris dan kolom yang tepat. Zero overhead di production.
|
||||
|
||||
**Hotkey**: `Ctrl+Shift+Cmd+C` (macOS) / `Ctrl+Shift+Alt+C` → aktifkan mode inspect → klik elemen → file terbuka.
|
||||
|
||||
## Kenapa Tidak Pakai Library
|
||||
|
||||
`react-dev-inspector` crash di React 19 karena:
|
||||
- `fiber.return.child.sibling` bisa null di React 19
|
||||
- `_debugSource` dihapus dari React 19
|
||||
- Walking fiber tree tidak stabil antar versi React
|
||||
|
||||
Solusi ini **regex-based + multi-fallback**, tidak bergantung pada React internals.
|
||||
|
||||
## Syarat Arsitektur
|
||||
|
||||
Fitur ini bekerja karena 4 syarat struktural terpenuhi. Jika salah satu tidak ada, fitur tidak bisa diimplementasi atau perlu adaptasi signifikan.
|
||||
|
||||
### 1. Vite sebagai Bundler (Wajib)
|
||||
|
||||
Seluruh mekanisme bergantung pada **Vite plugin transform pipeline**:
|
||||
- `inspectorPlugin()` inject attributes ke JSX saat build/HMR
|
||||
- `enforce: 'pre'` memastikan plugin jalan sebelum OXC/Babel transform JSX
|
||||
- `import.meta.env?.DEV` sebagai compile-time constant untuk tree-shaking
|
||||
|
||||
**Tidak bisa diganti dengan**: esbuild standalone, webpack (perlu loader berbeda), SWC standalone.
|
||||
**Bisa diganti dengan**: framework yang pakai Vite di dalamnya (Remix Vite, TanStack Start, Astro).
|
||||
|
||||
### 2. Server dan Frontend dalam Satu Proses (Wajib)
|
||||
|
||||
Endpoint `/__open-in-editor` harus **satu proses dengan dev server** yang melayani frontend:
|
||||
- Browser POST ke origin yang sama (no CORS)
|
||||
- Server punya akses ke filesystem lokal untuk `Bun.spawn(editor)`
|
||||
- Endpoint harus bisa ditangani **sebelum routing & middleware** (auth, tenant, dll)
|
||||
|
||||
**Pola yang memenuhi syarat:**
|
||||
- Elysia + Vite middlewareMode (project ini) — `onRequest` intercept sebelum route matching
|
||||
- Express/Fastify + Vite middlewareMode — middleware biasa sebelum auth
|
||||
- Vite dev server standalone (`vite dev`) — pakai `configureServer` hook
|
||||
|
||||
**Tidak memenuhi syarat:**
|
||||
- Frontend dan backend di proses/port terpisah (misal: CRA + separate API server) — perlu proxy atau CORS config tambahan
|
||||
- Serverless/edge deployment — tidak bisa `spawn` editor
|
||||
|
||||
### 3. React sebagai UI Framework (Wajib untuk Multi-Fallback)
|
||||
|
||||
Strategi extraction source info bergantung pada React internals:
|
||||
1. `__reactProps$*` — React menyimpan props di DOM element
|
||||
2. `__reactFiber$*` — React fiber tree untuk walk-up
|
||||
3. DOM attribute — fallback universal
|
||||
|
||||
**Jika pakai framework lain** (Vue, Svelte, Solid):
|
||||
- Hanya strategi 3 (DOM attribute) yang berfungsi — tetap cukup
|
||||
- Hapus strategi 1 & 2 dari `getCodeInfoFromElement()`
|
||||
- Inject attributes tetap via Vite plugin (framework-agnostic)
|
||||
|
||||
### 4. Bun sebagai Runtime (Direkomendasikan, Bukan Wajib)
|
||||
|
||||
Bun memberikan API yang lebih clean:
|
||||
- `Bun.spawn()` — fire-and-forget tanpa import
|
||||
- `Bun.which()` — cek executable ada di PATH (mencegah uncatchable error)
|
||||
|
||||
**Jika pakai Node.js:**
|
||||
- `Bun.spawn()` → `child_process.spawn(editor, args, { detached: true, stdio: 'ignore' }).unref()`
|
||||
- `Bun.which()` → `const which = require('which'); which.sync(editor, { nothrow: true })`
|
||||
|
||||
### Ringkasan Syarat
|
||||
|
||||
| Syarat | Wajib? | Alternatif |
|
||||
|-------------------------------|----------|------------------------------------------------------|
|
||||
| Vite sebagai bundler | Ya | Framework berbasis Vite (Remix, Astro, dll) |
|
||||
| Server + frontend satu proses | Ya | Bisa diakali dengan proxy, tapi tambah kompleksitas |
|
||||
| React | Sebagian | Framework lain bisa, hanya fallback ke DOM attribute |
|
||||
| Bun runtime | Tidak | Node.js dengan `child_process` + `which` package |
|
||||
|
||||
## Arsitektur
|
||||
|
||||
```
|
||||
BUILD TIME (Vite Plugin):
|
||||
.tsx/.jsx file
|
||||
→ [inspectorPlugin enforce:'pre'] inject data-inspector-* attributes ke JSX
|
||||
→ [react() OXC] transform JSX ke createElement
|
||||
→ Browser menerima elemen dengan attributes
|
||||
|
||||
RUNTIME (Browser):
|
||||
Hotkey → aktifkan mode → hover elemen → baca attributes → klik
|
||||
→ POST /__open-in-editor {relativePath, line, column}
|
||||
|
||||
BACKEND (Elysia onRequest):
|
||||
/__open-in-editor → Bun.spawn([editor, '--goto', 'file:line:col'])
|
||||
→ Editor terbuka di lokasi tepat
|
||||
```
|
||||
|
||||
## Komponen yang Dibutuhkan
|
||||
|
||||
### 1. Vite Plugin — `inspectorPlugin()` (enforce: 'pre')
|
||||
|
||||
Inject `data-inspector-*` ke setiap JSX opening tag via regex.
|
||||
|
||||
**HARUS `enforce: 'pre'`** — kalau tidak, OXC transform JSX duluan dan regex tidak bisa menemukan `<Component`.
|
||||
|
||||
```typescript
|
||||
// Taruh di file vite config (misal: src/vite.ts atau vite.config.ts)
|
||||
import path from 'node:path'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
function inspectorPlugin(): Plugin {
|
||||
const rootDir = process.cwd()
|
||||
|
||||
return {
|
||||
name: 'inspector-inject',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
// Hanya .tsx/.jsx, skip node_modules
|
||||
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
|
||||
if (!code.includes('<')) return null
|
||||
|
||||
const relativePath = path.relative(rootDir, id)
|
||||
let modified = false
|
||||
const lines = code.split('\n')
|
||||
const result: string[] = []
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]
|
||||
// Match JSX opening tags: <Component atau <div
|
||||
// Skip TypeScript generics (Record<string>) via charBefore check
|
||||
const jsxPattern = /(<(?:[A-Z][a-zA-Z0-9.]*|[a-z][a-zA-Z0-9-]*))\b/g
|
||||
let match: RegExpExecArray | null = null
|
||||
|
||||
while ((match = jsxPattern.exec(line)) !== null) {
|
||||
// Skip jika karakter sebelum `<` adalah identifier char (TypeScript generic)
|
||||
const charBefore = match.index > 0 ? line[match.index - 1] : ''
|
||||
if (/[a-zA-Z0-9_$.]/.test(charBefore)) continue
|
||||
|
||||
const col = match.index + 1
|
||||
const attr = ` data-inspector-line="${i + 1}" data-inspector-column="${col}" data-inspector-relative-path="${relativePath}"`
|
||||
const insertPos = match.index + match[0].length
|
||||
line = line.slice(0, insertPos) + attr + line.slice(insertPos)
|
||||
modified = true
|
||||
jsxPattern.lastIndex += attr.length
|
||||
}
|
||||
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
if (!modified) return null
|
||||
return result.join('\n')
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Mengapa regex, bukan Babel?**
|
||||
- `@vitejs/plugin-react` v6+ pakai OXC (Rust), bukan Babel
|
||||
- Config `babel: { plugins: [...] }` di plugin-react **DIABAIKAN**
|
||||
- Regex jalan sebelum OXC via `enforce: 'pre'`
|
||||
|
||||
**Gotcha: TypeScript generics**
|
||||
- `Record<string>` → karakter sebelum `<` adalah `d` (identifier) → SKIP
|
||||
- `<Button` → karakter sebelum `<` adalah space/newline → MATCH
|
||||
|
||||
### 2. Vite Plugin Order (KRITIS)
|
||||
|
||||
```typescript
|
||||
plugins: [
|
||||
// 1. Route generation (jika pakai TanStack Router)
|
||||
TanStackRouterVite({ ... }),
|
||||
|
||||
// 2. Inspector inject — HARUS sebelum react()
|
||||
inspectorPlugin(),
|
||||
|
||||
// 3. React OXC transform
|
||||
react(),
|
||||
|
||||
// 4. (Opsional) Dedupe React Refresh untuk middlewareMode
|
||||
dedupeRefreshPlugin(),
|
||||
]
|
||||
```
|
||||
|
||||
**Jika urutan salah (inspectorPlugin setelah react):**
|
||||
- OXC transform `<Button>` → `React.createElement(Button, ...)`
|
||||
- Regex tidak menemukan `<Button` → attributes TIDAK ter-inject
|
||||
- Fitur tidak berfungsi, tanpa error
|
||||
|
||||
### 3. DevInspector Component (Browser Runtime)
|
||||
|
||||
Komponen React yang handle hotkey, overlay, dan klik.
|
||||
|
||||
```tsx
|
||||
// src/frontend/DevInspector.tsx
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface CodeInfo {
|
||||
relativePath: string
|
||||
line: string
|
||||
column: string
|
||||
}
|
||||
|
||||
/** Baca data-inspector-* dari fiber props atau DOM attributes */
|
||||
function getCodeInfoFromElement(element: HTMLElement): CodeInfo | null {
|
||||
// Strategi 1: React internal props __reactProps$ (paling akurat)
|
||||
for (const key of Object.keys(element)) {
|
||||
if (key.startsWith('__reactProps$')) {
|
||||
const props = (element as any)[key]
|
||||
if (props?.['data-inspector-relative-path']) {
|
||||
return {
|
||||
relativePath: props['data-inspector-relative-path'],
|
||||
line: props['data-inspector-line'] || '1',
|
||||
column: props['data-inspector-column'] || '1',
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strategi 2: Walk fiber tree __reactFiber$
|
||||
if (key.startsWith('__reactFiber$')) {
|
||||
const fiber = (element as any)[key]
|
||||
let f = fiber
|
||||
while (f) {
|
||||
const p = f.pendingProps || f.memoizedProps
|
||||
if (p?.['data-inspector-relative-path']) {
|
||||
return {
|
||||
relativePath: p['data-inspector-relative-path'],
|
||||
line: p['data-inspector-line'] || '1',
|
||||
column: p['data-inspector-column'] || '1',
|
||||
}
|
||||
}
|
||||
// Fallback: _debugSource (React < 19)
|
||||
const src = f._debugSource ?? f._debugOwner?._debugSource
|
||||
if (src?.fileName && src?.lineNumber) {
|
||||
return {
|
||||
relativePath: src.fileName,
|
||||
line: String(src.lineNumber),
|
||||
column: String(src.columnNumber ?? 1),
|
||||
}
|
||||
}
|
||||
f = f.return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategi 3: Fallback DOM attribute langsung
|
||||
const rp = element.getAttribute('data-inspector-relative-path')
|
||||
if (rp) {
|
||||
return {
|
||||
relativePath: rp,
|
||||
line: element.getAttribute('data-inspector-line') || '1',
|
||||
column: element.getAttribute('data-inspector-column') || '1',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Walk up DOM tree sampai ketemu elemen yang punya source info */
|
||||
function findCodeInfo(target: HTMLElement): CodeInfo | null {
|
||||
let el: HTMLElement | null = target
|
||||
while (el) {
|
||||
const info = getCodeInfoFromElement(el)
|
||||
if (info) return info
|
||||
el = el.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function openInEditor(info: CodeInfo) {
|
||||
fetch('/__open-in-editor', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
relativePath: info.relativePath,
|
||||
lineNumber: info.line,
|
||||
columnNumber: info.column,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function DevInspector({ children }: { children: React.ReactNode }) {
|
||||
const [active, setActive] = useState(false)
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastInfoRef = useRef<CodeInfo | null>(null)
|
||||
|
||||
const updateOverlay = useCallback((target: HTMLElement | null) => {
|
||||
const ov = overlayRef.current
|
||||
const tt = tooltipRef.current
|
||||
if (!ov || !tt) return
|
||||
|
||||
if (!target) {
|
||||
ov.style.display = 'none'
|
||||
tt.style.display = 'none'
|
||||
lastInfoRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const info = findCodeInfo(target)
|
||||
if (!info) {
|
||||
ov.style.display = 'none'
|
||||
tt.style.display = 'none'
|
||||
lastInfoRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
lastInfoRef.current = info
|
||||
|
||||
const rect = target.getBoundingClientRect()
|
||||
ov.style.display = 'block'
|
||||
ov.style.top = `${rect.top + window.scrollY}px`
|
||||
ov.style.left = `${rect.left + window.scrollX}px`
|
||||
ov.style.width = `${rect.width}px`
|
||||
ov.style.height = `${rect.height}px`
|
||||
|
||||
tt.style.display = 'block'
|
||||
tt.textContent = `${info.relativePath}:${info.line}`
|
||||
const ttTop = rect.top + window.scrollY - 24
|
||||
tt.style.top = `${ttTop > 0 ? ttTop : rect.bottom + window.scrollY + 4}px`
|
||||
tt.style.left = `${rect.left + window.scrollX}px`
|
||||
}, [])
|
||||
|
||||
// Activate/deactivate event listeners
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
|
||||
const onMouseOver = (e: MouseEvent) => updateOverlay(e.target as HTMLElement)
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const info = lastInfoRef.current ?? findCodeInfo(e.target as HTMLElement)
|
||||
if (info) {
|
||||
const loc = `${info.relativePath}:${info.line}:${info.column}`
|
||||
console.log('[DevInspector] Open:', loc)
|
||||
navigator.clipboard.writeText(loc)
|
||||
openInEditor(info)
|
||||
}
|
||||
setActive(false)
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setActive(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', onMouseOver, true)
|
||||
document.addEventListener('click', onClick, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.body.style.cursor = 'crosshair'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseover', onMouseOver, true)
|
||||
document.removeEventListener('click', onClick, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.cursor = ''
|
||||
if (overlayRef.current) overlayRef.current.style.display = 'none'
|
||||
if (tooltipRef.current) tooltipRef.current.style.display = 'none'
|
||||
}
|
||||
}, [active, updateOverlay])
|
||||
|
||||
// Hotkey: Ctrl+Shift+Cmd+C (macOS) / Ctrl+Shift+Alt+C
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() === 'c' && e.ctrlKey && e.shiftKey && (e.metaKey || e.altKey)) {
|
||||
e.preventDefault()
|
||||
setActive((prev) => !prev)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
border: '2px solid #3b82f6',
|
||||
backgroundColor: 'rgba(59,130,246,0.1)',
|
||||
zIndex: 99999,
|
||||
transition: 'all 0.05s ease',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
zIndex: 100000,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Backend Endpoint — `/__open-in-editor`
|
||||
|
||||
**HARUS ditangani di `onRequest` / sebelum middleware**, bukan sebagai route biasa. Kalau jadi route, akan kena auth middleware dan gagal.
|
||||
|
||||
```typescript
|
||||
// Di entry point server (src/index.tsx), dalam onRequest handler:
|
||||
|
||||
if (!isProduction && pathname === '/__open-in-editor' && request.method === 'POST') {
|
||||
const { relativePath, lineNumber, columnNumber } = (await request.json()) as {
|
||||
relativePath: string
|
||||
lineNumber: string
|
||||
columnNumber: string
|
||||
}
|
||||
const file = `${process.cwd()}/${relativePath}`
|
||||
const editor = process.env.REACT_EDITOR || 'code'
|
||||
const loc = `${file}:${lineNumber}:${columnNumber}`
|
||||
const args = editor === 'subl' ? [loc] : ['--goto', loc]
|
||||
const editorPath = Bun.which(editor)
|
||||
console.log(`[inspector] ${editor} → ${editorPath ?? 'NOT FOUND'} → ${loc}`)
|
||||
if (editorPath) {
|
||||
Bun.spawn([editor, ...args], { stdio: ['ignore', 'ignore', 'ignore'] })
|
||||
} else {
|
||||
console.error(`[inspector] Editor "${editor}" not found in PATH. Set REACT_EDITOR in .env`)
|
||||
}
|
||||
return new Response('ok')
|
||||
}
|
||||
```
|
||||
|
||||
**Penting — `Bun.which()` sebelum `Bun.spawn()`:**
|
||||
- `Bun.spawn()` throw native error yang TIDAK bisa di-catch jika executable tidak ada
|
||||
- `Bun.which()` return null dengan aman → cek dulu sebelum spawn
|
||||
|
||||
**Editor yang didukung:**
|
||||
|
||||
| REACT_EDITOR | Editor | Args |
|
||||
|------------------|--------------|--------------------------------|
|
||||
| `code` (default) | VS Code | `--goto file:line:col` |
|
||||
| `cursor` | Cursor | `--goto file:line:col` |
|
||||
| `windsurf` | Windsurf | `--goto file:line:col` |
|
||||
| `subl` | Sublime Text | `file:line:col` (tanpa --goto) |
|
||||
|
||||
### 5. Frontend Entry — Conditional Import (Zero Production Overhead)
|
||||
|
||||
```tsx
|
||||
// src/frontend.tsx (atau entry point React)
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const InspectorWrapper = import.meta.env?.DEV
|
||||
? (await import('./frontend/DevInspector')).DevInspector
|
||||
: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
|
||||
const app = (
|
||||
<InspectorWrapper>
|
||||
<App />
|
||||
</InspectorWrapper>
|
||||
)
|
||||
```
|
||||
|
||||
**Bagaimana zero overhead tercapai:**
|
||||
- `import.meta.env?.DEV` adalah compile-time constant
|
||||
- Production build: `false` → dynamic import TIDAK dieksekusi
|
||||
- Tree-shaking menghapus seluruh `DevInspector.tsx` dari bundle
|
||||
- Tidak ada runtime check, tidak ada dead code di bundle
|
||||
|
||||
### 6. (Opsional) Dedupe React Refresh — Workaround Vite middlewareMode
|
||||
|
||||
Jika pakai Vite dalam `middlewareMode` (seperti di Elysia/Express), `@vitejs/plugin-react` v6 bisa inject React Refresh footer dua kali → error "already declared".
|
||||
|
||||
```typescript
|
||||
function dedupeRefreshPlugin(): Plugin {
|
||||
return {
|
||||
name: 'dedupe-react-refresh',
|
||||
enforce: 'post',
|
||||
transform(code, id) {
|
||||
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
|
||||
|
||||
const marker = 'import * as RefreshRuntime from "/@react-refresh"'
|
||||
const firstIdx = code.indexOf(marker)
|
||||
if (firstIdx === -1) return null
|
||||
|
||||
const secondIdx = code.indexOf(marker, firstIdx + marker.length)
|
||||
if (secondIdx === -1) return null
|
||||
|
||||
const sourcemapIdx = code.indexOf('\n//# sourceMappingURL=', secondIdx)
|
||||
const endIdx = sourcemapIdx !== -1 ? sourcemapIdx : code.length
|
||||
|
||||
const cleaned = code.slice(0, secondIdx) + code.slice(endIdx)
|
||||
return { code: cleaned, map: null }
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Langkah Implementasi di Project Baru
|
||||
|
||||
### Prasyarat
|
||||
- Runtime: Bun
|
||||
- Server: Elysia (atau framework lain dengan onRequest/beforeHandle)
|
||||
- Frontend: React + Vite
|
||||
- `@vitejs/plugin-react` (OXC)
|
||||
|
||||
### Step-by-step
|
||||
|
||||
1. **Buat `DevInspector.tsx`** — copy komponen dari Bagian 3 ke folder frontend
|
||||
2. **Tambah `inspectorPlugin()`** — copy fungsi dari Bagian 1 ke file vite config
|
||||
3. **Atur plugin order** — `inspectorPlugin()` SEBELUM `react()` (Bagian 2)
|
||||
4. **Tambah endpoint `/__open-in-editor`** — di `onRequest` handler (Bagian 4)
|
||||
5. **Wrap root app** — conditional import di entry point (Bagian 5)
|
||||
6. **Set env** — `REACT_EDITOR=code` (atau cursor/windsurf/subl) di `.env`
|
||||
7. **(Opsional)** Tambah `dedupeRefreshPlugin()` jika pakai Vite `middlewareMode`
|
||||
|
||||
### Checklist Verifikasi
|
||||
|
||||
- [ ] `inspectorPlugin` punya `enforce: 'pre'`
|
||||
- [ ] Plugin order: inspector → react (bukan sebaliknya)
|
||||
- [ ] Endpoint `/__open-in-editor` di LUAR middleware auth
|
||||
- [ ] `Bun.which(editor)` dipanggil SEBELUM `Bun.spawn()`
|
||||
- [ ] Conditional import pakai `import.meta.env?.DEV`
|
||||
- [ ] `REACT_EDITOR` di `.env` sesuai editor yang dipakai
|
||||
- [ ] Hotkey berfungsi: `Ctrl+Shift+Cmd+C` / `Ctrl+Shift+Alt+C`
|
||||
|
||||
## Gotcha & Pelajaran
|
||||
|
||||
| Masalah | Penyebab | Solusi |
|
||||
|----------------------------------|---------------------------------------------|-----------------------------------------------|
|
||||
| Attributes tidak ter-inject | Plugin order salah | `enforce: 'pre'`, taruh sebelum `react()` |
|
||||
| `Record<string>` ikut ter-inject | Regex match TypeScript generics | Cek `charBefore` — skip jika identifier char |
|
||||
| `Bun.spawn` crash | Editor tidak ada di PATH | Selalu `Bun.which()` dulu |
|
||||
| Hotkey tidak response | `e.key` return 'C' (uppercase) karena Shift | Pakai `e.key.toLowerCase()` |
|
||||
| React Refresh duplicate | Vite middlewareMode bug | `dedupeRefreshPlugin()` enforce: 'post' |
|
||||
| Endpoint kena auth middleware | Didaftarkan sebagai route biasa | Tangani di `onRequest` sebelum routing |
|
||||
| `_debugSource` undefined | React 19 menghapusnya | Multi-fallback: reactProps → fiber → DOM attr |
|
||||
|
||||
## Adaptasi untuk Framework Lain
|
||||
|
||||
### Express/Fastify (bukan Elysia)
|
||||
- Endpoint `/__open-in-editor`: gunakan middleware biasa SEBELUM auth
|
||||
- `Bun.spawn` → `child_process.spawn` jika pakai Node.js
|
||||
- `Bun.which` → `which` npm package jika pakai Node.js
|
||||
|
||||
### Next.js
|
||||
- Tidak perlu — Next.js punya built-in click-to-source
|
||||
- Tapi jika ingin custom: taruh endpoint di `middleware.ts`, plugin di `next.config.js`
|
||||
|
||||
### Remix/Tanstack Start (SSR)
|
||||
- Plugin tetap sama (Vite-based)
|
||||
- Endpoint perlu di server entry, bukan di route loader
|
||||
13
docker-entrypoint.sh
Normal file
13
docker-entrypoint.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🔄 Running database migrations..."
|
||||
cd /app
|
||||
bunx prisma migrate deploy || {
|
||||
echo "❌ Migration failed!"
|
||||
exit 1
|
||||
}
|
||||
echo "✅ Migrations completed successfully"
|
||||
|
||||
echo "🚀 Starting application..."
|
||||
exec bun start
|
||||
@@ -1,380 +0,0 @@
|
||||
# State Management Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Desa Darmasaba menggunakan **Valtio** untuk global state management. Valtio adalah state management library yang menggunakan proxy pattern untuk reactive state yang sederhana dan performant.
|
||||
|
||||
## Why Valtio?
|
||||
|
||||
- ✅ **Simple API** - Menggunakan plain JavaScript objects
|
||||
- ✅ **Performant** - Component re-renders hanya saat state yang digunakan berubah
|
||||
- ✅ **TypeScript-friendly** - Full TypeScript support
|
||||
- ✅ **No boilerplate** - Tidak perlu actions, reducers, atau selectors
|
||||
- ✅ **Flexible** - Bisa digunakan di dalam atau luar React components
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun install valtio
|
||||
```
|
||||
|
||||
## State Structure
|
||||
|
||||
```
|
||||
src/state/
|
||||
├── admin/ # Admin dashboard state
|
||||
│ ├── index.ts # Admin state exports
|
||||
│ ├── adminNavState.ts # Navigation state
|
||||
│ ├── adminAuthState.ts # Authentication state
|
||||
│ ├── adminFormState.ts # Form state (images, files)
|
||||
│ └── adminModuleState.ts # Module-specific state
|
||||
│
|
||||
├── public/ # Public pages state
|
||||
│ ├── index.ts # Public state exports
|
||||
│ ├── publicNavState.ts # Navigation state
|
||||
│ └── publicMusicState.ts # Music player state
|
||||
│
|
||||
├── darkModeStore.ts # Dark mode state (legacy)
|
||||
└── index.ts # Main exports
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating State
|
||||
|
||||
```typescript
|
||||
// src/state/example/exampleState.ts
|
||||
import { proxy, useSnapshot } from 'valtio';
|
||||
|
||||
export const exampleState = proxy<{
|
||||
count: number;
|
||||
items: string[];
|
||||
isLoading: boolean;
|
||||
increment: () => void;
|
||||
addItem: (item: string) => void;
|
||||
}>({
|
||||
count: 0,
|
||||
items: [],
|
||||
isLoading: false,
|
||||
|
||||
increment() {
|
||||
exampleState.count += 1;
|
||||
},
|
||||
|
||||
addItem(item: string) {
|
||||
exampleState.items.push(item);
|
||||
},
|
||||
});
|
||||
|
||||
// Hook untuk React components
|
||||
export const useExample = () => {
|
||||
const snapshot = useSnapshot(exampleState);
|
||||
return {
|
||||
...snapshot,
|
||||
increment: exampleState.increment,
|
||||
addItem: exampleState.addItem,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Using in React Components
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useExample } from '@/state';
|
||||
|
||||
export function Counter() {
|
||||
const { count, increment } = useExample();
|
||||
|
||||
return (
|
||||
<button onClick={increment}>
|
||||
Count: {count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using Outside React
|
||||
|
||||
```typescript
|
||||
// In non-React code (utilities, services, etc.)
|
||||
import { exampleState } from '@/state';
|
||||
|
||||
// Direct mutation
|
||||
exampleState.count = 10;
|
||||
exampleState.increment();
|
||||
|
||||
// Subscribe to changes
|
||||
import { subscribe } from 'valtio';
|
||||
|
||||
subscribe(exampleState, () => {
|
||||
console.log('State changed:', exampleState.count);
|
||||
});
|
||||
```
|
||||
|
||||
## Domain-Specific State
|
||||
|
||||
### Admin State
|
||||
|
||||
State untuk admin dashboard hanya digunakan di `/admin` routes.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
adminNavState,
|
||||
adminAuthState,
|
||||
useAdminNav,
|
||||
useAdminAuth
|
||||
} from '@/state';
|
||||
|
||||
// In React component
|
||||
export function AdminHeader() {
|
||||
const { mobileOpen, toggleMobile } = useAdminNav();
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Button onClick={toggleMobile}>Menu</Button>
|
||||
{user?.name}
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
// Outside React
|
||||
adminNavState.mobileOpen = true;
|
||||
adminAuthState.clearUser();
|
||||
```
|
||||
|
||||
### Public State
|
||||
|
||||
State untuk public pages hanya digunakan di `/darmasaba` routes.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
publicNavState,
|
||||
publicMusicState,
|
||||
usePublicNav,
|
||||
usePublicMusic
|
||||
} from '@/state';
|
||||
|
||||
// In React component
|
||||
export function MusicPlayer() {
|
||||
const { isPlaying, currentSong, togglePlayPause } = usePublicMusic();
|
||||
|
||||
return (
|
||||
<Player>
|
||||
{currentSong?.judul}
|
||||
<Button onClick={togglePlayPause}>
|
||||
{isPlaying ? 'Pause' : 'Play'}
|
||||
</Button>
|
||||
</Player>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Async Operations
|
||||
|
||||
```typescript
|
||||
// src/state/example/dataState.ts
|
||||
import { proxy, useSnapshot } from 'valtio';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
|
||||
export const dataState = proxy<{
|
||||
data: any[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
fetchData: (id: string) => Promise<void>;
|
||||
}>({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
async fetchData(id: string) {
|
||||
dataState.isLoading = true;
|
||||
dataState.error = null;
|
||||
|
||||
try {
|
||||
const response = await ApiFetch.someApi.get({ id });
|
||||
dataState.data = response.data;
|
||||
} catch (error) {
|
||||
dataState.error = error instanceof Error ? error.message : 'Failed to fetch';
|
||||
} finally {
|
||||
dataState.isLoading = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const useData = () => {
|
||||
const snapshot = useSnapshot(dataState);
|
||||
return {
|
||||
...snapshot,
|
||||
fetchData: dataState.fetchData,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
|
||||
1. **Separate admin and public state**
|
||||
```typescript
|
||||
// Good
|
||||
import { adminNavState } from '@/state/admin';
|
||||
import { publicNavState } from '@/state/public';
|
||||
```
|
||||
|
||||
2. **Use methods in state for complex operations**
|
||||
```typescript
|
||||
// Good
|
||||
export const state = proxy({
|
||||
count: 0,
|
||||
increment() {
|
||||
state.count += 1;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
3. **Add error handling in async methods**
|
||||
```typescript
|
||||
// Good
|
||||
async fetchData() {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
// fetch logic
|
||||
} catch (error) {
|
||||
state.error = error.message;
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Use TypeScript for type safety**
|
||||
```typescript
|
||||
// Good
|
||||
type User = { id: string; name: string };
|
||||
|
||||
export const authState = proxy<{
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
}>({ ... });
|
||||
```
|
||||
|
||||
### ❌ DON'T
|
||||
|
||||
1. **Don't mutate state directly in render**
|
||||
```typescript
|
||||
// Bad
|
||||
function Component() {
|
||||
state.count += 1; // Don't do this in render
|
||||
return <div>{state.count}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Don't mix admin and public state**
|
||||
```typescript
|
||||
// Bad
|
||||
import { adminAuthState } from '@/state/admin';
|
||||
import { publicNavState } from '@/state/public';
|
||||
|
||||
// Don't use admin state in public pages
|
||||
```
|
||||
|
||||
3. **Don't create new objects in state methods**
|
||||
```typescript
|
||||
// Bad
|
||||
increment() {
|
||||
state.count = state.count + 1; // Creates new number
|
||||
}
|
||||
|
||||
// Good
|
||||
increment() {
|
||||
state.count += 1; // Mutates existing value
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Legacy State
|
||||
|
||||
### Old Pattern (Deprecated)
|
||||
|
||||
```typescript
|
||||
// Old pattern - still works but deprecated
|
||||
import stateNav from '@/state/state-nav';
|
||||
import { authStore } from '@/store/authStore';
|
||||
```
|
||||
|
||||
### New Pattern (Recommended)
|
||||
|
||||
```typescript
|
||||
// New pattern - recommended
|
||||
import { adminNavState } from '@/state/admin';
|
||||
import { adminAuthState } from '@/state/admin';
|
||||
```
|
||||
|
||||
## Music Player State
|
||||
|
||||
Music player sekarang menggunakan Valtio state dengan React Context wrapper untuk backward compatibility.
|
||||
|
||||
```typescript
|
||||
// New way (recommended)
|
||||
import { usePublicMusic } from '@/state/public';
|
||||
|
||||
function MusicPlayer() {
|
||||
const { isPlaying, currentSong, togglePlayPause } = usePublicMusic();
|
||||
// ...
|
||||
}
|
||||
|
||||
// Old way (still works for backward compatibility)
|
||||
import { useMusic } from '@/app/context/MusicContext';
|
||||
|
||||
function MusicPlayer() {
|
||||
const { isPlaying, currentSong, togglePlayPause } = useMusic();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### State not updating in component
|
||||
|
||||
Make sure you're using the hook in component:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
function Component() {
|
||||
const { count } = useExample(); // Subscribe to state
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
|
||||
// Bad
|
||||
function Component() {
|
||||
const count = exampleState.count; // No subscription
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Performance issues
|
||||
|
||||
Use selective subscriptions:
|
||||
|
||||
```typescript
|
||||
// Good - only subscribe to what you need
|
||||
function Component() {
|
||||
const { count } = useExample(); // Only count
|
||||
return <div>{count}</div>;
|
||||
}
|
||||
|
||||
// Bad - subscribe to entire state
|
||||
function Component() {
|
||||
const state = useExample(); // Entire state
|
||||
return <div>{state.count}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Valtio Documentation](https://github.com/pmndrs/valtio)
|
||||
- [Valtio Examples](https://github.com/pmndrs/valtio/tree/main/examples)
|
||||
- [Reactivity Guide](https://docs.pmnd.rs/valtio/guides/reactivity)
|
||||
540
docs/TESTING.md
540
docs/TESTING.md
@@ -1,540 +0,0 @@
|
||||
# Testing Guide - Desa Darmasaba
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides comprehensive testing guidelines for the Desa Darmasaba project. The project uses a multi-layered testing strategy including unit tests, component tests, and end-to-end (E2E) tests.
|
||||
|
||||
## Testing Stack
|
||||
|
||||
| Layer | Tool | Purpose |
|
||||
|-------|------|---------|
|
||||
| **Unit Tests** | Vitest | Testing utility functions, validation schemas, services |
|
||||
| **Component Tests** | Vitest + React Testing Library | Testing React components in isolation |
|
||||
| **E2E Tests** | Playwright | Testing complete user flows in real browsers |
|
||||
| **API Mocking** | MSW (Mock Service Worker) | Mocking API responses for unit/component tests |
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
__tests__/
|
||||
├── api/ # API integration tests
|
||||
│ └── fileStorage.test.ts
|
||||
├── components/ # Component tests
|
||||
│ └── admin/
|
||||
│ ├── UnifiedTypography.test.tsx
|
||||
│ └── UnifiedSurface.test.tsx
|
||||
├── e2e/ # End-to-end tests
|
||||
│ ├── admin/
|
||||
│ │ └── auth.spec.ts
|
||||
│ └── public/
|
||||
│ └── pages.spec.ts
|
||||
├── lib/ # Unit tests for utilities
|
||||
│ ├── validations.test.ts
|
||||
│ ├── sanitizer.test.ts
|
||||
│ └── whatsapp.test.ts
|
||||
├── mocks/ # MSW mocks for API
|
||||
│ ├── handlers.ts
|
||||
│ └── server.ts
|
||||
└── setup.ts # Test setup and configuration
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### All Tests
|
||||
```bash
|
||||
bun run test
|
||||
```
|
||||
|
||||
### Unit Tests Only
|
||||
```bash
|
||||
bun run test:api
|
||||
```
|
||||
|
||||
### E2E Tests Only
|
||||
```bash
|
||||
bun run test:e2e
|
||||
```
|
||||
|
||||
### Tests with Coverage
|
||||
```bash
|
||||
bun run test:api --coverage
|
||||
```
|
||||
|
||||
### Run Specific Test File
|
||||
```bash
|
||||
bunx vitest run __tests__/lib/validations.test.ts
|
||||
```
|
||||
|
||||
### Run Tests in Watch Mode
|
||||
```bash
|
||||
bunx vitest
|
||||
```
|
||||
|
||||
### Run E2E Tests with UI
|
||||
```bash
|
||||
bun run test:e2e --ui
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Unit Tests (Vitest)
|
||||
|
||||
Unit tests should test pure functions, validation schemas, and utilities in isolation.
|
||||
|
||||
```typescript
|
||||
// __tests__/lib/example.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { exampleFunction } from '@/lib/example';
|
||||
|
||||
describe('exampleFunction', () => {
|
||||
it('should return expected value for valid input', () => {
|
||||
const result = exampleFunction('valid-input');
|
||||
expect(result).toBe('expected-output');
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(() => exampleFunction('')).toThrow();
|
||||
expect(() => exampleFunction(null)).toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Component Tests (React Testing Library)
|
||||
|
||||
Component tests should test React components in isolation with mocked dependencies.
|
||||
|
||||
```typescript
|
||||
// __tests__/components/Example.test.tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider, createTheme } from '@mantine/core';
|
||||
import { ExampleComponent } from '@/components/Example';
|
||||
|
||||
function renderWithMantine(ui: React.ReactElement) {
|
||||
const theme = createTheme();
|
||||
return render(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<MantineProvider theme={theme}>{children}</MantineProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe('ExampleComponent', () => {
|
||||
it('should render with props', () => {
|
||||
renderWithMantine(<ExampleComponent title="Test Title" />);
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle user interactions', async () => {
|
||||
const onClick = vi.fn();
|
||||
renderWithMantine(<ExampleComponent onClick={onClick} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
|
||||
E2E tests should test complete user flows in a real browser environment.
|
||||
|
||||
```typescript
|
||||
// __tests__/e2e/example.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Setup before each test
|
||||
await page.goto('/starting-page');
|
||||
});
|
||||
|
||||
test('should complete user flow', async ({ page }) => {
|
||||
// Fill form
|
||||
await page.fill('input[name="email"]', 'user@example.com');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL('/success');
|
||||
|
||||
// Verify result
|
||||
await expect(page.getByText('Success!')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle errors gracefully', async ({ page }) => {
|
||||
// Submit invalid data
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Verify error message
|
||||
await expect(page.getByText('Validation error')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### API Mocking (MSW)
|
||||
|
||||
Use MSW to mock API responses for unit and component tests.
|
||||
|
||||
```typescript
|
||||
// __tests__/mocks/handlers.ts
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
export const handlers = [
|
||||
http.get('/api/example', () => {
|
||||
return HttpResponse.json({
|
||||
data: [{ id: '1', name: 'Item 1' }],
|
||||
});
|
||||
}),
|
||||
|
||||
http.post('/api/example', async ({ request }) => {
|
||||
const body = await request.json();
|
||||
return HttpResponse.json({
|
||||
data: { id: '2', ...body },
|
||||
status: 201,
|
||||
});
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
## Test Coverage Goals
|
||||
|
||||
Current coverage thresholds (configured in `vitest.config.ts`):
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Branches | 50% |
|
||||
| Functions | 50% |
|
||||
| Lines | 50% |
|
||||
| Statements | 50% |
|
||||
|
||||
### Critical Files Priority
|
||||
|
||||
Focus testing efforts on these critical files first:
|
||||
|
||||
1. **Validation & Security**
|
||||
- `src/lib/validations/index.ts`
|
||||
- `src/lib/sanitizer.ts`
|
||||
- `src/lib/whatsapp.ts`
|
||||
- `src/lib/session.ts`
|
||||
|
||||
2. **Core Utilities**
|
||||
- `src/lib/api-fetch.ts`
|
||||
- `src/lib/prisma.ts`
|
||||
- `src/utils/themeTokens.ts`
|
||||
|
||||
3. **Shared Components**
|
||||
- `src/components/admin/UnifiedTypography.tsx`
|
||||
- `src/components/admin/UnifiedSurface.tsx`
|
||||
- `src/components/admin/UnifiedCard.tsx`
|
||||
|
||||
4. **State Management**
|
||||
- `src/state/darkModeStore.ts`
|
||||
- `src/state/admin/*.ts`
|
||||
- `src/state/public/*.ts`
|
||||
|
||||
5. **API Routes**
|
||||
- `src/app/api/[[...slugs]]/_lib/auth/**`
|
||||
- `src/app/api/[[...slugs]]/_lib/desa/**`
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Unit/Component Tests**: `*.test.ts` or `*.test.tsx`
|
||||
- **E2E Tests**: `*.spec.ts`
|
||||
- **Test Files**: Match source file name (e.g., `sanitizer.ts` → `sanitizer.test.ts`)
|
||||
- **Test Directories**: Mirror source structure under `__tests__/`
|
||||
|
||||
### Describe Blocks
|
||||
|
||||
Use nested `describe` blocks to organize tests logically:
|
||||
|
||||
```typescript
|
||||
describe('FeatureName', () => {
|
||||
describe('functionName', () => {
|
||||
describe('when valid input', () => {
|
||||
it('should return expected result', () => {});
|
||||
});
|
||||
|
||||
describe('when invalid input', () => {
|
||||
it('should throw error', () => {});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Test Descriptions
|
||||
|
||||
- Use clear, descriptive test names
|
||||
- Follow pattern: `should [expected behavior] when [condition]`
|
||||
- Avoid vague descriptions like "works correctly"
|
||||
|
||||
### Assertions
|
||||
|
||||
- Use specific matchers (`toBe`, `toEqual`, `toContain`)
|
||||
- Test both success and failure cases
|
||||
- Test edge cases (empty input, null, undefined, max values)
|
||||
|
||||
### Setup and Teardown
|
||||
|
||||
```typescript
|
||||
describe('ComponentName', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks, state
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ... tests
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking Guidelines
|
||||
|
||||
### Mock External Services
|
||||
|
||||
```typescript
|
||||
// Mock fetch API
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Mock modules
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
default: {
|
||||
berita: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Mock Environment Variables
|
||||
|
||||
```typescript
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
TEST_VAR: 'test-value',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Date/Time
|
||||
|
||||
```typescript
|
||||
const mockDate = new Date('2024-01-01T00:00:00Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(mockDate);
|
||||
|
||||
// ... tests
|
||||
|
||||
vi.useRealTimers();
|
||||
```
|
||||
|
||||
## E2E Testing Best Practices
|
||||
|
||||
### Test User Flows, Not Implementation
|
||||
|
||||
✅ Good:
|
||||
```typescript
|
||||
test('user can login and view dashboard', async ({ page }) => {
|
||||
await page.goto('/admin/login');
|
||||
await page.fill('input[name="nomor"]', '08123456789');
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL('/admin/dashboard');
|
||||
});
|
||||
```
|
||||
|
||||
❌ Bad:
|
||||
```typescript
|
||||
test('login form submits to API', async ({ page }) => {
|
||||
// Don't test internal implementation details
|
||||
});
|
||||
```
|
||||
|
||||
### Use Data Attributes for Selectors
|
||||
|
||||
```typescript
|
||||
// In component
|
||||
<button data-testid="submit-button">Submit</button>
|
||||
|
||||
// In test
|
||||
await page.getByTestId('submit-button').click();
|
||||
```
|
||||
|
||||
### Handle Async Operations
|
||||
|
||||
```typescript
|
||||
// Wait for specific element
|
||||
await page.waitForSelector('.loaded-content');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForNavigation();
|
||||
|
||||
// Wait for network request
|
||||
await page.waitForResponse('/api/data');
|
||||
```
|
||||
|
||||
### Skip Tests Appropriately
|
||||
|
||||
```typescript
|
||||
// Skip in CI
|
||||
test.skip(process.env.CI === 'true', 'Skip in CI environment');
|
||||
|
||||
// Skip with reason
|
||||
test.skip(true, 'Feature not yet implemented');
|
||||
|
||||
// Conditional skip
|
||||
test.skip(!hasValidCredentials, 'Requires valid credentials');
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions Workflow
|
||||
|
||||
Tests run automatically on:
|
||||
- Pull requests
|
||||
- Push to main branch
|
||||
- Manual trigger
|
||||
|
||||
### Test Requirements
|
||||
|
||||
- All new features must include tests
|
||||
- Bug fixes should include regression tests
|
||||
- Coverage should not decrease significantly
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Vitest Debug Mode
|
||||
|
||||
```bash
|
||||
bunx vitest --reporter=verbose
|
||||
```
|
||||
|
||||
### Playwright Debug Mode
|
||||
|
||||
```bash
|
||||
PWDEBUG=1 bun run test:e2e
|
||||
```
|
||||
|
||||
### Playwright Trace Viewer
|
||||
|
||||
```bash
|
||||
bun run test:e2e --trace on
|
||||
bunx playwright show-trace
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Testing Validation Schemas
|
||||
|
||||
```typescript
|
||||
describe('validationSchema', () => {
|
||||
it('should accept valid data', () => {
|
||||
const result = validationSchema.safeParse(validData);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid data', () => {
|
||||
const result = validationSchema.safeParse(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.errors[0].message).toContain('error message');
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Async Functions
|
||||
|
||||
```typescript
|
||||
it('should fetch data successfully', async () => {
|
||||
const result = await fetchData();
|
||||
expect(result).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
await expect(asyncFunction()).rejects.toThrow('error message');
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Hooks
|
||||
|
||||
```typescript
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
it('should update state', () => {
|
||||
const { result } = renderHook(() => useCustomHook());
|
||||
|
||||
act(() => {
|
||||
result.current.setValue('new value');
|
||||
});
|
||||
|
||||
expect(result.current.value).toBe('new value');
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue**: Tests fail with "Cannot find module"
|
||||
**Solution**: Check import paths, ensure `@/` alias is configured in `vitest.config.ts`
|
||||
|
||||
**Issue**: Mantine components throw errors
|
||||
**Solution**: Wrap components with `MantineProvider` in test setup
|
||||
|
||||
**Issue**: Tests fail in CI but pass locally
|
||||
**Solution**: Check for environment-specific code, use proper mocking
|
||||
|
||||
**Issue**: E2E tests timeout
|
||||
**Solution**: Increase timeout, check for async operations, use proper waits
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check existing tests for patterns
|
||||
- Review Vitest documentation: https://vitest.dev
|
||||
- Review Playwright documentation: https://playwright.dev
|
||||
- Review Testing Library documentation: https://testing-library.com
|
||||
|
||||
## Resources
|
||||
|
||||
- [Vitest Documentation](https://vitest.dev)
|
||||
- [Playwright Documentation](https://playwright.dev)
|
||||
- [React Testing Library](https://testing-library.com/react)
|
||||
- [MSW Documentation](https://mswjs.io)
|
||||
- [Testing JavaScript Course](https://testingjavascript.com)
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Regular Tasks
|
||||
|
||||
- [ ] Update test dependencies monthly
|
||||
- [ ] Review and update test coverage goals quarterly
|
||||
- [ ] Remove deprecated test patterns
|
||||
- [ ] Add tests for newly discovered edge cases
|
||||
- [ ] Document common testing patterns
|
||||
|
||||
### Deprecation Policy
|
||||
|
||||
When refactoring code:
|
||||
1. Keep existing tests passing
|
||||
2. Update tests to match new implementation
|
||||
3. Remove tests for removed functionality
|
||||
4. Update this documentation
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: March 9, 2026
|
||||
**Version**: 1.0.0
|
||||
**Maintained By**: Development Team
|
||||
@@ -11,6 +11,11 @@ const compat = new FlatCompat({
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ['@elysiajs/static', 'elysia'],
|
||||
experimental: {},
|
||||
allowedDevOrigins: [
|
||||
"http://192.168.1.82:3000", // buat akses dari HP/device lain
|
||||
|
||||
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "desa-darmasaba",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -8,7 +8,8 @@
|
||||
"start": "next start",
|
||||
"test:api": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test": "bun run test:api && bun run test:e2e"
|
||||
"test": "bun run test:api && bun run test:e2e",
|
||||
"gen:api": ""
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "bun run prisma/seed.ts"
|
||||
@@ -70,7 +71,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"form-data": "^4.0.2",
|
||||
"framer-motion": "^12.23.5",
|
||||
"framer-motion": "^12.38.0",
|
||||
"get-port": "^7.1.0",
|
||||
"iron-session": "^8.0.4",
|
||||
"jose": "^6.1.0",
|
||||
@@ -100,7 +101,7 @@
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"readdirp": "^4.1.1",
|
||||
"recharts": "^2.15.3",
|
||||
"recharts": "^3.8.0",
|
||||
"sharp": "^0.34.3",
|
||||
"swr": "^2.3.2",
|
||||
"uuid": "^11.1.0",
|
||||
@@ -120,14 +121,16 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"eslint-config-next": "15.5.12",
|
||||
"jsdom": "^28.0.0",
|
||||
"msw": "^2.12.9",
|
||||
"parcel": "^2.6.2",
|
||||
"playwright-mcp": "^0.0.19",
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
57
prisma/_seeder_list/core/seed_app_menu.ts
Normal file
57
prisma/_seeder_list/core/seed_app_menu.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const appMenuJson = loadJsonData("core/app-menu.json");
|
||||
const appMenuChildJson = loadJsonData("core/app-menu-child.json");
|
||||
|
||||
export async function seedAppMenu() {
|
||||
console.log("🔄 Seeding AppMenu...");
|
||||
|
||||
for (const item of appMenuJson) {
|
||||
await prisma.appMenu.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
link: item.link,
|
||||
isActive: item.isActive,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
link: item.link,
|
||||
isActive: item.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ AppMenu seeded: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("🎉 AppMenu seed selesai");
|
||||
}
|
||||
|
||||
export async function seedAppMenuChild() {
|
||||
console.log("🔄 Seeding AppMenuChild...");
|
||||
|
||||
for (const item of appMenuChildJson) {
|
||||
await prisma.appMenuChild.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
link: item.link,
|
||||
isActive: item.isActive,
|
||||
appMenuId: item.appMenuId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
link: item.link,
|
||||
isActive: item.isActive,
|
||||
appMenuId: item.appMenuId,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ AppMenuChild seeded: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("🎉 AppMenuChild seed selesai");
|
||||
}
|
||||
69
prisma/_seeder_list/core/seed_core.ts
Normal file
69
prisma/_seeder_list/core/seed_core.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const layananJson = loadJsonData("core/layanan.json");
|
||||
const potensiJson = loadJsonData("core/potensi.json");
|
||||
const landingPageLayananJson = loadJsonData("core/landingpage-layanan.json");
|
||||
|
||||
export async function seedLayananCore() {
|
||||
console.log("🔄 Seeding Layanan...");
|
||||
|
||||
for (const item of layananJson) {
|
||||
await prisma.layanan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Layanan seeded: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("🎉 Layanan seed selesai");
|
||||
}
|
||||
|
||||
export async function seedPotensiCore() {
|
||||
console.log("🔄 Seeding Potensi...");
|
||||
|
||||
for (const item of potensiJson) {
|
||||
await prisma.potensi.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Potensi seeded: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("🎉 Potensi seed selesai");
|
||||
}
|
||||
|
||||
export async function seedLandingPageLayanan() {
|
||||
console.log("🔄 Seeding LandingPage_Layanan...");
|
||||
|
||||
for (const item of landingPageLayananJson) {
|
||||
await prisma.landingPage_Layanan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
deksripsi: item.deksripsi,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
deksripsi: item.deksripsi,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ LandingPage_Layanan seeded: ${item.id}`);
|
||||
}
|
||||
|
||||
console.log("🎉 LandingPage_Layanan seed selesai");
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import kategoriBerita from "../../../data/desa/berita/kategori-berita.json";
|
||||
import beritaJson from "../../../data/desa/berita/berita.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const kategoriBerita = loadJsonData("desa/berita/kategori-berita.json");
|
||||
const beritaJson = loadJsonData("desa/berita/berita.json");
|
||||
|
||||
export async function seedBerita() {
|
||||
// ================== SUBMENU BERITA ========================
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import foto from "../../../../data/desa/gallery/foto/foto.json";
|
||||
import { loadJsonData } from "../../../../load-json";
|
||||
|
||||
const foto = loadJsonData("desa/gallery/foto/foto.json");
|
||||
|
||||
export async function seedFoto() {
|
||||
console.log("🔄 Seeding Foto...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import galleryVideo from "../../../../data/desa/gallery/video/video.json";
|
||||
import { loadJsonData } from "../../../../load-json";
|
||||
|
||||
const galleryVideo = loadJsonData("desa/gallery/video/video.json");
|
||||
|
||||
export async function seedVideo() {
|
||||
console.log("🔄 Seeding Gallery Video...");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import pelayananSuratKeterangan from "../../../data/desa/layanan/pelayananSuratKeterangan.json";
|
||||
import pelayananTelunjukSaktiDesa from "../../../data/desa/layanan/pelayananTelunjukSaktiDesa.json";
|
||||
import pelayananPerizinanBerusaha from "../../../data/desa/layanan/pelayananPerizinanBerusaha.json";
|
||||
import pelayananPendudukNonPermanen from "../../../data/desa/layanan/pelayananPendudukNonPermanen.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const pelayananSuratKeterangan = loadJsonData("desa/layanan/pelayananSuratKeterangan.json");
|
||||
const pelayananTelunjukSaktiDesa = loadJsonData("desa/layanan/pelayananTelunjukSaktiDesa.json");
|
||||
const pelayananPerizinanBerusaha = loadJsonData("desa/layanan/pelayananPerizinanBerusaha.json");
|
||||
const pelayananPendudukNonPermanen = loadJsonData("desa/layanan/pelayananPendudukNonPermanen.json");
|
||||
|
||||
export async function seedLayanan() {
|
||||
console.log("🔄 Seeding Pelayanan Surat Keterangan...");
|
||||
|
||||
57
prisma/_seeder_list/desa/musik-desa/seed_musik_desa.ts
Normal file
57
prisma/_seeder_list/desa/musik-desa/seed_musik_desa.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const musikJson = loadJsonData("desa/musik-desa/musik-desa.json");
|
||||
|
||||
export async function seedMusikDesa() {
|
||||
console.log("Seeding Musik Desa...");
|
||||
|
||||
for (const item of musikJson) {
|
||||
let audioFileId: string | null = null;
|
||||
let coverImageId: string | null = null;
|
||||
|
||||
if (item.audioFileName) {
|
||||
const audio = await prisma.fileStorage.findUnique({
|
||||
where: { name: item.audioFileName },
|
||||
select: { id: true },
|
||||
});
|
||||
if (audio) audioFileId = audio.id;
|
||||
}
|
||||
|
||||
if (item.coverImageName) {
|
||||
const cover = await prisma.fileStorage.findUnique({
|
||||
where: { name: item.coverImageName },
|
||||
select: { id: true },
|
||||
});
|
||||
if (cover) coverImageId = cover.id;
|
||||
}
|
||||
|
||||
await prisma.musikDesa.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
judul: item.judul,
|
||||
artis: item.artis,
|
||||
deskripsi: item.deskripsi,
|
||||
durasi: item.durasi,
|
||||
audioFileId,
|
||||
coverImageId,
|
||||
genre: item.genre,
|
||||
tahunRilis: item.tahunRilis,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
judul: item.judul,
|
||||
artis: item.artis,
|
||||
deskripsi: item.deskripsi,
|
||||
durasi: item.durasi,
|
||||
audioFileId,
|
||||
coverImageId,
|
||||
genre: item.genre,
|
||||
tahunRilis: item.tahunRilis,
|
||||
},
|
||||
});
|
||||
console.log(` Musik: ${item.judul} - ${item.artis}`);
|
||||
}
|
||||
|
||||
console.log("Musik Desa seed selesai");
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import penghargaan from "../../../data/desa/penghargaan/penghargaan.json"
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const penghargaan = loadJsonData("desa/penghargaan/penghargaan.json");
|
||||
|
||||
export async function seedPenghargaan() {
|
||||
console.log("🔄 Seeding Penghargaan...");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
import { safeSeedUnique } from "../../../safeseedUnique";
|
||||
import kategoriPengumuman from "../../../data/desa/pengumuman/kategori-pengumuman.json";
|
||||
import pengumuman from "../../../data/desa/pengumuman/pengumuman.json";
|
||||
|
||||
const kategoriPengumuman = loadJsonData("desa/pengumuman/kategori-pengumuman.json");
|
||||
const pengumuman = loadJsonData("desa/pengumuman/pengumuman.json");
|
||||
|
||||
export async function seedPengumuman() {
|
||||
console.log("🔄 Seeding Kategori Pengumuman...");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import kategoriPotensi from "../../../data/desa/potensi/kategori-potensi.json";
|
||||
import potensiDesa from "../../../data/desa/potensi/potensi-desa.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const kategoriPotensi = loadJsonData("desa/potensi/kategori-potensi.json");
|
||||
const potensiDesa = loadJsonData("desa/potensi/potensi-desa.json");
|
||||
|
||||
export async function seedPotensi() {
|
||||
console.log("🔄Seeding Kategori Potensi Desa ...");
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import lambangDesa from "../../../data/desa/profile/lambang_desa.json";
|
||||
import maskotDesa from "../../../data/desa/profile/maskot_desa.json";
|
||||
import profilePerbekel from "../../../data/desa/profile/profil_perbekel.json";
|
||||
import profileDesaImage from "../../../data/desa/profile/profileDesaImage.json";
|
||||
import sejarahDesa from "../../../data/desa/profile/sejarah_desa.json";
|
||||
import visiMisiDesa from "../../../data/desa/profile/visi_misi_desa.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const lambangDesa = loadJsonData("desa/profile/lambang_desa.json");
|
||||
const maskotDesa = loadJsonData("desa/profile/maskot_desa.json");
|
||||
const profilePerbekel = loadJsonData("desa/profile/profil_perbekel.json");
|
||||
const profileDesaImage = loadJsonData("desa/profile/profileDesaImage.json");
|
||||
const sejarahDesa = loadJsonData("desa/profile/sejarah_desa.json");
|
||||
const visiMisiDesa = loadJsonData("desa/profile/visi_misi_desa.json");
|
||||
|
||||
export async function seedProfileDesa() {
|
||||
// =========== SEJARAH DESA ===========
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import perbekelDariMasaKeMasa from "../../../data/desa/profile/profile-perbekel-lalu.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const perbekelDariMasaKeMasa = loadJsonData("desa/profile/profile-perbekel-lalu.json");
|
||||
|
||||
export async function seedProfilePerbekel() {
|
||||
console.log("🔄 Seeding Perbekel Dari Masa Ke Masa...");
|
||||
|
||||
45
prisma/_seeder_list/ekonomi/seed_apbdes.ts
Normal file
45
prisma/_seeder_list/ekonomi/seed_apbdes.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const apbdesJson = loadJsonData("ekonomi/apbdes/apbdes.json");
|
||||
|
||||
export async function seedAPBDes() {
|
||||
console.log("Seeding APBDes...");
|
||||
|
||||
for (const item of apbdesJson) {
|
||||
let imageId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
|
||||
if (item.imageName) {
|
||||
const image = await prisma.fileStorage.findUnique({
|
||||
where: { name: item.imageName },
|
||||
select: { id: true },
|
||||
});
|
||||
if (image) imageId = image.id;
|
||||
}
|
||||
|
||||
await prisma.aPBDes.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
tahun: item.tahun,
|
||||
name: item.name,
|
||||
deskripsi: item.deskripsi,
|
||||
jumlah: item.jumlah,
|
||||
imageId,
|
||||
fileId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
tahun: item.tahun,
|
||||
name: item.name,
|
||||
deskripsi: item.deskripsi,
|
||||
jumlah: item.jumlah,
|
||||
imageId,
|
||||
fileId,
|
||||
},
|
||||
});
|
||||
console.log(` APBDes: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("APBDes seed selesai");
|
||||
}
|
||||
63
prisma/_seeder_list/ekonomi/seed_apbdes_item.ts
Normal file
63
prisma/_seeder_list/ekonomi/seed_apbdes_item.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const itemsJson = loadJsonData("ekonomi/apbdes/apbdes-items.json");
|
||||
const realisasiJson = loadJsonData("ekonomi/apbdes/realisasi-items.json");
|
||||
|
||||
export async function seedAPBDesItem() {
|
||||
console.log("Seeding APBDes Items...");
|
||||
|
||||
// Seed items first (sorted by level to ensure parents exist)
|
||||
const sortedItems = [...itemsJson].sort((a, b) => a.level - b.level);
|
||||
|
||||
for (const item of sortedItems) {
|
||||
await prisma.aPBDesItem.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
tipe: item.tipe,
|
||||
level: item.level,
|
||||
parentId: item.parentId,
|
||||
apbdesId: item.apbdesId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
tipe: item.tipe,
|
||||
level: item.level,
|
||||
parentId: item.parentId,
|
||||
apbdesId: item.apbdesId,
|
||||
},
|
||||
});
|
||||
console.log(` APBDes Item: ${item.kode} - ${item.uraian}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Realisasi Items...");
|
||||
for (const item of realisasiJson) {
|
||||
await prisma.realisasiItem.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
kode: item.kode,
|
||||
apbdesItemId: item.apbdesItemId,
|
||||
jumlah: item.jumlah,
|
||||
tanggal: new Date(item.tanggal),
|
||||
keterangan: item.keterangan,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
kode: item.kode,
|
||||
apbdesItemId: item.apbdesItemId,
|
||||
jumlah: item.jumlah,
|
||||
tanggal: new Date(item.tanggal),
|
||||
keterangan: item.keterangan,
|
||||
},
|
||||
});
|
||||
console.log(` Realisasi: ${item.kode} - Rp ${item.jumlah.toLocaleString("id-ID")}`);
|
||||
}
|
||||
|
||||
console.log("APBDes Item & Realisasi seed selesai");
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import demografiPekerjaan from "../../data/ekonomi/demografi-pekerjaan/demografi-pekerjaan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const demografiPekerjaan = loadJsonData("ekonomi/demografi-pekerjaan/demografi-pekerjaan.json");
|
||||
|
||||
export async function seedDemografiPekerjaan() {
|
||||
console.log("🔄 Seeding Demografi Pekerjaan...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import jumlahPendudukMiskin from "../../data/ekonomi/jumlah-penduduk-miskin/jumlah-penduduk-miskin.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const jumlahPendudukMiskin = loadJsonData("ekonomi/jumlah-penduduk-miskin/jumlah-penduduk-miskin.json");
|
||||
|
||||
export async function seedJumlahPendudukMiskin() {
|
||||
console.log("🔄 Seeding Jumlah Penduduk Miskin...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import jumlahPengangguran from "../../data/ekonomi/jumlah-pengangguran/detail-data-pengangguran.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const jumlahPengangguran = loadJsonData("ekonomi/jumlah-pengangguran/detail-data-pengangguran.json");
|
||||
|
||||
export async function seedJumlahPengangguran() {
|
||||
for (const d of jumlahPengangguran) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import lowonganKerjaLokal from "../../data/ekonomi/lowongan-kerja-lokal/lowongan-kerja-lokal.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const lowonganKerjaLokal = loadJsonData("ekonomi/lowongan-kerja-lokal/lowongan-kerja-lokal.json");
|
||||
|
||||
export async function seedLowonganKerjaLokal() {
|
||||
console.log("🔄 Seeding Lowongan Kerja Lokal...");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import kategoriProduk from "../../data/ekonomi/pasar-desa/kategori-produk.json";
|
||||
import pasarDesa from "../../data/ekonomi/pasar-desa/pasar-desa.json";
|
||||
import kategoriToPasar from "../../data/ekonomi/pasar-desa/kategori-to-pasar.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const kategoriProduk = loadJsonData("ekonomi/pasar-desa/kategori-produk.json");
|
||||
const pasarDesa = loadJsonData("ekonomi/pasar-desa/pasar-desa.json");
|
||||
const kategoriToPasar = loadJsonData("ekonomi/pasar-desa/kategori-to-pasar.json");
|
||||
|
||||
export async function seedPasarDesa() {
|
||||
console.log("🔄 Seeding Kategori Produk...");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import apbdes from "../../data/ekonomi/pendapatan-asli-desa/apbDesa.json";
|
||||
import pendapatan from "../../data/ekonomi/pendapatan-asli-desa/pendapatanDesa.json";
|
||||
import belanja from "../../data/ekonomi/pendapatan-asli-desa/belanjaDesa.json";
|
||||
import pembiayaan from "../../data/ekonomi/pendapatan-asli-desa/pembiayaanDesa.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const apbdes = loadJsonData("ekonomi/pendapatan-asli-desa/apbDesa.json");
|
||||
const pendapatan = loadJsonData("ekonomi/pendapatan-asli-desa/pendapatanDesa.json");
|
||||
const belanja = loadJsonData("ekonomi/pendapatan-asli-desa/belanjaDesa.json");
|
||||
const pembiayaan = loadJsonData("ekonomi/pendapatan-asli-desa/pembiayaanDesa.json");
|
||||
|
||||
export async function seedPendapatanAsli() {
|
||||
console.log("🔄 Seeding Pendapatan Asli...");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import grafikMenganggurBerdasarkanUsia from "../../data/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran-berdasarkan-usia.json";
|
||||
import grafikMenganggurBerdasarkanPendidikan from "../../data/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran-berdasarkan-pendidikan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const grafikMenganggurBerdasarkanUsia = loadJsonData("ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran-berdasarkan-usia.json");
|
||||
const grafikMenganggurBerdasarkanPendidikan = loadJsonData("ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran-berdasarkan-pendidikan.json");
|
||||
|
||||
export async function seedPendudukUsiaKerjaYangMenganggur() {
|
||||
for (const p of grafikMenganggurBerdasarkanUsia) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import programKemiskinan from "../../data/ekonomi/program-kemiskinan/program-kemiskinan.json";
|
||||
import statistikKemiskinan from "../../data/ekonomi/program-kemiskinan/statistik-kemiskinan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const programKemiskinan = loadJsonData("ekonomi/program-kemiskinan/program-kemiskinan.json");
|
||||
const statistikKemiskinan = loadJsonData("ekonomi/program-kemiskinan/statistik-kemiskinan.json");
|
||||
|
||||
export async function seedProgramKemiskinan() {
|
||||
for (const s of statistikKemiskinan) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import sektorUnggulanDesa from "../../data/ekonomi/sektor-unggulan/sektor-unggulan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const sektorUnggulanDesa = loadJsonData("ekonomi/sektor-unggulan/sektor-unggulan.json");
|
||||
|
||||
export async function seedSektorUnggulanDesa() {
|
||||
console.log("🔄 Seeding Sektor Unggulan Desa...");
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import posisiOrganisasiBumDes from "../../data/ekonomi/struktur-organisasi/posisi-organisasi-bumdes.json";
|
||||
import pegawai from "../../data/ekonomi/struktur-organisasi/pegawai-bumdes.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
interface PosisiOrganisasi {
|
||||
id: string;
|
||||
nama: string;
|
||||
deskripsi: string;
|
||||
hierarki: number;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
interface PegawaiBumDes {
|
||||
id: string;
|
||||
namaLengkap: string;
|
||||
gelarAkademik: string;
|
||||
tanggalMasuk: string;
|
||||
email: string;
|
||||
telepon: string;
|
||||
alamat: string;
|
||||
posisiId: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const posisiOrganisasiBumDes = loadJsonData<PosisiOrganisasi[][]>("ekonomi/struktur-organisasi/posisi-organisasi-bumdes.json");
|
||||
const pegawai = loadJsonData<PegawaiBumDes[]>("ekonomi/struktur-organisasi/pegawai-bumdes.json");
|
||||
|
||||
export async function seedStrukturBumdes() {
|
||||
const flattenedPosisi = posisiOrganisasiBumDes.flat();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import ajukanIde from "../../data/inovasi/ajukan-ide/ajukan-ide.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const ajukanIde = loadJsonData("inovasi/ajukan-ide/ajukan-ide.json");
|
||||
|
||||
export async function seedAjukan() {
|
||||
console.log("🔄 Seeding Ajukan Ide Inovatif...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import desaDigital from "../../data/inovasi/desa-digital/desa-digital.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const desaDigital = loadJsonData("inovasi/desa-digital/desa-digital.json");
|
||||
|
||||
export async function seedDesaDigital() {
|
||||
console.log("🔄 Seeding Desa Digital...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import infoTeknologi from "../../data/inovasi/info-teknologi/info-teknologi.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const infoTeknologi = loadJsonData("inovasi/info-teknologi/info-teknologi.json");
|
||||
|
||||
export async function seedInfoTeknologi() {
|
||||
console.log("🔄 Seeding Info Teknologi...");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import kolaborasiInovasi from "../../data/inovasi/kolaborasi-inovasi/kolaborasi-inovasi.json";
|
||||
import mitraKolaborasi from "../../data/inovasi/kolaborasi-inovasi/mitra-kolaborasi.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const kolaborasiInovasi = loadJsonData("inovasi/kolaborasi-inovasi/kolaborasi-inovasi.json");
|
||||
const mitraKolaborasi = loadJsonData("inovasi/kolaborasi-inovasi/mitra-kolaborasi.json");
|
||||
|
||||
export async function seedKolaborasiInovasi() {
|
||||
console.log("🔄 Seeding Kolaborasi Inovasi...");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import jenisLayanan from "../../data/inovasi/layanan-online-desa/jenis-layanan.json";
|
||||
import administrasiOnline from "../../data/inovasi/layanan-online-desa/administrasi-online.json";
|
||||
import jenisPengaduan from "../../data/inovasi/layanan-online-desa/jenis-pengaduan.json";
|
||||
import pengaduanMasyarakat from "../../data/inovasi/layanan-online-desa/pengaduan-masyarakat.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const jenisLayanan = loadJsonData("inovasi/layanan-online-desa/jenis-layanan.json");
|
||||
const administrasiOnline = loadJsonData("inovasi/layanan-online-desa/administrasi-online.json");
|
||||
const jenisPengaduan = loadJsonData("inovasi/layanan-online-desa/jenis-pengaduan.json");
|
||||
const pengaduanMasyarakat = loadJsonData("inovasi/layanan-online-desa/pengaduan-masyarakat.json");
|
||||
|
||||
export async function seedLayananOnlineDesa() {
|
||||
console.log("🔄 Seeding Jenis Layanan...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import programKreatif from "../../data/inovasi/program-kreatif-desa/program-kreatif-desa.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const programKreatif = loadJsonData("inovasi/program-kreatif-desa/program-kreatif-desa.json");
|
||||
|
||||
export async function seedProgramKreatifDesa() {
|
||||
console.log("🔄 Seeding Program Kreatif...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import keamananLingkunganJson from "../../data/keamanan/keamanan-lingkungan/keamanan-lingkungan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const keamananLingkunganJson = loadJsonData("keamanan/keamanan-lingkungan/keamanan-lingkungan.json");
|
||||
|
||||
export async function seedKeamananLingkungan() {
|
||||
console.log("🔄 Seeding Keamanan Lingkungan...");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import kontakDaruratKeamanan from "../../data/keamanan/kontak-darurat-keamanan/kontak-darurat-keamanan.json";
|
||||
import kontakItem from "../../data/keamanan/kontak-darurat-keamanan/kontakItem.json";
|
||||
import kontakDaruratToItem from "../../data/keamanan/kontak-darurat-keamanan/kontakDaruratToItem.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const kontakDaruratKeamanan = loadJsonData("keamanan/kontak-darurat-keamanan/kontak-darurat-keamanan.json");
|
||||
const kontakItem = loadJsonData("keamanan/kontak-darurat-keamanan/kontakItem.json");
|
||||
const kontakDaruratToItem = loadJsonData("keamanan/kontak-darurat-keamanan/kontakDaruratToItem.json");
|
||||
|
||||
export async function seedKontakDaruratKeamanan() {
|
||||
console.log("🔄 Seeding Kontak Item...");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import laporanPublik from "../../data/keamanan/laporan-publik/laporan-publik.json";
|
||||
import penangananLaporan from "../../data/keamanan/laporan-publik/penanganan-laporan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const laporanPublik = loadJsonData("keamanan/laporan-publik/laporan-publik.json");
|
||||
const penangananLaporan = loadJsonData("keamanan/laporan-publik/penanganan-laporan.json");
|
||||
|
||||
export async function seedLaporanPublik() {
|
||||
console.log("🔄 Seeding Laporan Publik...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import pencegahanKriminalitas from "../../data/keamanan/pencegahan-kriminalitas/pencegahan-kriminalitas.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const pencegahanKriminalitas = loadJsonData("keamanan/pencegahan-kriminalitas/pencegahan-kriminalitas.json");
|
||||
|
||||
export async function seedPencegahanKriminalitas() {
|
||||
console.log("🔄 Seeding Pencegahan Kriminalitas...");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import layananPolsek from "../../data/keamanan/polsek-terdekat/layanan-polsek.json";
|
||||
import polsekTerdekat from "../../data/keamanan/polsek-terdekat/polsek-terdekat.json";
|
||||
import layananToPolsek from "../../data/keamanan/polsek-terdekat/layanan-to-polsek.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const layananPolsek = loadJsonData("keamanan/polsek-terdekat/layanan-polsek.json");
|
||||
const polsekTerdekat = loadJsonData("keamanan/polsek-terdekat/polsek-terdekat.json");
|
||||
const layananToPolsek = loadJsonData("keamanan/polsek-terdekat/layanan-to-polsek.json");
|
||||
|
||||
export async function seedPolsekTerdekat() {
|
||||
console.log("🔄 Seeding Layanan Polsek...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import tipsKeamananJson from "../../data/keamanan/tips-keamanan/tips-keamanan.json";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const tipsKeamananJson = loadJsonData("keamanan/tips-keamanan/tips-keamanan.json");
|
||||
|
||||
export async function seedTipsKeamanan() {
|
||||
console.log("🔄 Seeding Tips Keamanan...");
|
||||
|
||||
32
prisma/_seeder_list/kependudukan/seed_data_banjar.ts
Normal file
32
prisma/_seeder_list/kependudukan/seed_data_banjar.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const dataBanjarJson = loadJsonData("kependudukan/data-banjar/data-banjar.json");
|
||||
|
||||
export async function seedDataBanjar() {
|
||||
console.log("Seeding Data Banjar...");
|
||||
|
||||
for (const item of dataBanjarJson) {
|
||||
await prisma.dataBanjar.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
nama: item.nama,
|
||||
penduduk: item.penduduk,
|
||||
kk: item.kk,
|
||||
miskin: item.miskin,
|
||||
tahun: item.tahun,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
nama: item.nama,
|
||||
penduduk: item.penduduk,
|
||||
kk: item.kk,
|
||||
miskin: item.miskin,
|
||||
tahun: item.tahun,
|
||||
},
|
||||
});
|
||||
console.log(` Banjar: ${item.nama} (${item.penduduk} penduduk)`);
|
||||
}
|
||||
|
||||
console.log("Data Banjar seed selesai");
|
||||
}
|
||||
32
prisma/_seeder_list/kependudukan/seed_dinamika_penduduk.ts
Normal file
32
prisma/_seeder_list/kependudukan/seed_dinamika_penduduk.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const dinamikaPendudukJson = loadJsonData("kependudukan/dinamika-penduduk/dinamika-penduduk.json");
|
||||
|
||||
export async function seedDinamikaPenduduk() {
|
||||
console.log("Seeding Dinamika Penduduk...");
|
||||
|
||||
for (const item of dinamikaPendudukJson) {
|
||||
await prisma.dinamikaPenduduk.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
tahun: item.tahun,
|
||||
kelahiran: item.kelahiran,
|
||||
kematian: item.kematian,
|
||||
masuk: item.masuk,
|
||||
keluar: item.keluar,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
tahun: item.tahun,
|
||||
kelahiran: item.kelahiran,
|
||||
kematian: item.kematian,
|
||||
masuk: item.masuk,
|
||||
keluar: item.keluar,
|
||||
},
|
||||
});
|
||||
console.log(` Tahun ${item.tahun}: ${item.kelahiran} kelahiran, ${item.kematian} kematian`);
|
||||
}
|
||||
|
||||
console.log("Dinamika Penduduk seed selesai");
|
||||
}
|
||||
28
prisma/_seeder_list/kependudukan/seed_distribusi_agama.ts
Normal file
28
prisma/_seeder_list/kependudukan/seed_distribusi_agama.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const distribusiAgamaJson = loadJsonData("kependudukan/distribusi-agama/distribusi-agama.json");
|
||||
|
||||
export async function seedDistribusiAgama() {
|
||||
console.log("Seeding Distribusi Agama...");
|
||||
|
||||
for (const item of distribusiAgamaJson) {
|
||||
await prisma.distribusiAgama.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
agama: item.agama,
|
||||
jumlah: item.jumlah,
|
||||
tahun: item.tahun,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
agama: item.agama,
|
||||
jumlah: item.jumlah,
|
||||
tahun: item.tahun,
|
||||
},
|
||||
});
|
||||
console.log(` ${item.agama}: ${item.jumlah} penganut`);
|
||||
}
|
||||
|
||||
console.log("Distribusi Agama seed selesai");
|
||||
}
|
||||
28
prisma/_seeder_list/kependudukan/seed_distribusi_umur.ts
Normal file
28
prisma/_seeder_list/kependudukan/seed_distribusi_umur.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const distribusiUmurJson = loadJsonData("kependudukan/distribusi-umur/distribusi-umur.json");
|
||||
|
||||
export async function seedDistribusiUmur() {
|
||||
console.log("Seeding Distribusi Umur...");
|
||||
|
||||
for (const item of distribusiUmurJson) {
|
||||
await prisma.distribusiUmur.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
rentangUmur: item.rentangUmur,
|
||||
jumlah: item.jumlah,
|
||||
tahun: item.tahun,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
rentangUmur: item.rentangUmur,
|
||||
jumlah: item.jumlah,
|
||||
tahun: item.tahun,
|
||||
},
|
||||
});
|
||||
console.log(` Rentang ${item.rentangUmur}: ${item.jumlah} jiwa`);
|
||||
}
|
||||
|
||||
console.log("Distribusi Umur seed selesai");
|
||||
}
|
||||
34
prisma/_seeder_list/kependudukan/seed_migrasi_penduduk.ts
Normal file
34
prisma/_seeder_list/kependudukan/seed_migrasi_penduduk.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const migrasiPendudukJson = loadJsonData("kependudukan/migrasi-penduduk/migrasi-penduduk.json");
|
||||
|
||||
export async function seedMigrasiPenduduk() {
|
||||
console.log("Seeding MigrASI PENDUDUK...");
|
||||
|
||||
for (const item of migrasiPendudukJson) {
|
||||
await prisma.migrasiPenduduk.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
nama: item.nama,
|
||||
jenis: item.jenis,
|
||||
tanggal: new Date(item.tanggal),
|
||||
asal: item.asal,
|
||||
tujuan: item.tujuan,
|
||||
alasan: item.alasan,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
nama: item.nama,
|
||||
jenis: item.jenis,
|
||||
tanggal: new Date(item.tanggal),
|
||||
asal: item.asal,
|
||||
tujuan: item.tujuan,
|
||||
alasan: item.alasan,
|
||||
},
|
||||
});
|
||||
console.log(` ${item.nama}: ${item.jenis} (${item.alasan})`);
|
||||
}
|
||||
|
||||
console.log("Migrasi Penduduk seed selesai");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const artikelJson = loadJsonData("kesehatan/artikel-kesehatan/artikel-kesehatan.json");
|
||||
const introJson = loadJsonData("kesehatan/artikel-kesehatan/introduction.json");
|
||||
const symptomJson = loadJsonData("kesehatan/artikel-kesehatan/symptom.json");
|
||||
const preventionJson = loadJsonData("kesehatan/artikel-kesehatan/prevention.json");
|
||||
const firstAidJson = loadJsonData("kesehatan/artikel-kesehatan/first-aid.json");
|
||||
const mythVsFactJson = loadJsonData("kesehatan/artikel-kesehatan/myth-vs-fact.json");
|
||||
const doctorSignJson = loadJsonData("kesehatan/artikel-kesehatan/doctor-sign.json");
|
||||
|
||||
export async function seedArtikelKesehatan() {
|
||||
console.log("Seeding Introduction...");
|
||||
for (const item of introJson) {
|
||||
await prisma.introduction.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Symptom...");
|
||||
for (const item of symptomJson) {
|
||||
await prisma.symptom.upsert({
|
||||
where: { id: item.id },
|
||||
update: { title: item.title, content: item.content },
|
||||
create: { id: item.id, title: item.title, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Prevention...");
|
||||
for (const item of preventionJson) {
|
||||
await prisma.prevention.upsert({
|
||||
where: { id: item.id },
|
||||
update: { title: item.title, content: item.content },
|
||||
create: { id: item.id, title: item.title, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding First Aid...");
|
||||
for (const item of firstAidJson) {
|
||||
await prisma.firstAid.upsert({
|
||||
where: { id: item.id },
|
||||
update: { title: item.title, content: item.content },
|
||||
create: { id: item.id, title: item.title, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Myth vs Fact...");
|
||||
for (const item of mythVsFactJson) {
|
||||
await prisma.mythVsFact.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
title: item.title,
|
||||
mitos: item.mitos,
|
||||
fakta: item.fakta,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
mitos: item.mitos,
|
||||
fakta: item.fakta,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Doctor Sign...");
|
||||
for (const item of doctorSignJson) {
|
||||
await prisma.doctorSign.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Artikel Kesehatan...");
|
||||
for (const item of artikelJson) {
|
||||
await prisma.artikelKesehatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
introductionId: item.introductionId,
|
||||
symptomId: item.symptomId,
|
||||
preventionId: item.preventionId,
|
||||
firstAidId: item.firstAidId,
|
||||
mythVsFactId: item.mythVsFactId,
|
||||
doctorSignId: item.doctorSignId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
introductionId: item.introductionId,
|
||||
symptomId: item.symptomId,
|
||||
preventionId: item.preventionId,
|
||||
firstAidId: item.firstAidId,
|
||||
mythVsFactId: item.mythVsFactId,
|
||||
doctorSignId: item.doctorSignId,
|
||||
},
|
||||
});
|
||||
console.log(` Artikel: ${item.title}`);
|
||||
}
|
||||
|
||||
console.log("Artikel Kesehatan seed selesai");
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const faskesJson = loadJsonData("kesehatan/fasilitas-kesehatan/fasilitas-kesehatan.json");
|
||||
const infoUmumJson = loadJsonData("kesehatan/fasilitas-kesehatan/informasi-umum.json");
|
||||
const layananUnggulanJson = loadJsonData("kesehatan/fasilitas-kesehatan/layanan-unggulan.json");
|
||||
const dokterJson = loadJsonData("kesehatan/fasilitas-kesehatan/dokter-tenaga-medis.json");
|
||||
const fasilitasPendukungJson = loadJsonData("kesehatan/fasilitas-kesehatan/fasilitas-pendukung.json");
|
||||
const prosedurJson = loadJsonData("kesehatan/fasilitas-kesehatan/prosedur-pendaftaran.json");
|
||||
const tarifJson = loadJsonData("kesehatan/fasilitas-kesehatan/tarif-layanan.json");
|
||||
|
||||
export async function seedFasilitasKesehatan() {
|
||||
console.log("Seeding Informasi Umum...");
|
||||
for (const item of infoUmumJson) {
|
||||
await prisma.informasiUmum.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
fasilitas: item.fasilitas,
|
||||
alamat: item.alamat,
|
||||
jamOperasional: item.jamOperasional,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
fasilitas: item.fasilitas,
|
||||
alamat: item.alamat,
|
||||
jamOperasional: item.jamOperasional,
|
||||
},
|
||||
});
|
||||
console.log(` Informasi Umum: ${item.fasilitas}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Layanan Unggulan...");
|
||||
for (const item of layananUnggulanJson) {
|
||||
await prisma.layananUnggulan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Fasilitas Pendukung...");
|
||||
for (const item of fasilitasPendukungJson) {
|
||||
await prisma.fasilitasPendukung.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Prosedur Pendaftaran...");
|
||||
for (const item of prosedurJson) {
|
||||
await prisma.prosedurPendaftaran.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Tarif dan Layanan...");
|
||||
for (const item of tarifJson) {
|
||||
await prisma.tarifDanLayanan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { layanan: item.layanan, tarif: item.tarif },
|
||||
create: { id: item.id, layanan: item.layanan, tarif: item.tarif },
|
||||
});
|
||||
console.log(` Tarif: ${item.layanan}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Dokter dan Tenaga Medis...");
|
||||
for (const item of dokterJson) {
|
||||
await prisma.dokterdanTenagaMedis.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
specialist: item.specialist,
|
||||
jadwal: item.jadwal,
|
||||
jadwalLibur: item.jadwalLibur,
|
||||
jamBukaOperasional: item.jamBukaOperasional,
|
||||
jamTutupOperasional: item.jamTutupOperasional,
|
||||
jamBukaLibur: item.jamBukaLibur,
|
||||
jamTutupLibur: item.jamTutupLibur,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
specialist: item.specialist,
|
||||
jadwal: item.jadwal,
|
||||
jadwalLibur: item.jadwalLibur,
|
||||
jamBukaOperasional: item.jamBukaOperasional,
|
||||
jamTutupOperasional: item.jamTutupOperasional,
|
||||
jamBukaLibur: item.jamBukaLibur,
|
||||
jamTutupLibur: item.jamTutupLibur,
|
||||
},
|
||||
});
|
||||
console.log(` Dokter: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Fasilitas Kesehatan...");
|
||||
for (const item of faskesJson) {
|
||||
await prisma.fasilitasKesehatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
informasiUmumId: item.informasiUmumId,
|
||||
layananUnggulanId: item.layananUnggulanId,
|
||||
fasilitasPendukungId: item.fasilitasPendukungId,
|
||||
prosedurPendaftaranId: item.prosedurPendaftaranId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
informasiUmumId: item.informasiUmumId,
|
||||
layananUnggulanId: item.layananUnggulanId,
|
||||
fasilitasPendukungId: item.fasilitasPendukungId,
|
||||
prosedurPendaftaranId: item.prosedurPendaftaranId,
|
||||
},
|
||||
});
|
||||
console.log(` Fasilitas Kesehatan: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("Fasilitas Kesehatan seed selesai");
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import infoWabahPenyakitJson from "../../../data/kesehatan/infowabahpenyakit/infowabahpenyakit.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const infoWabahPenyakitJson = loadJsonData("kesehatan/infowabahpenyakit/infowabahpenyakit.json");
|
||||
|
||||
export async function seedInfoWabahPenyakit() {
|
||||
console.log("🔄 Seeding Info Wabah Penyakit...");
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const jadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/jadwal-kegiatan.json");
|
||||
const infoJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/informasi-jadwal.json");
|
||||
const deskJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/deskripsi-jadwal.json");
|
||||
const layananJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/layanan-jadwal.json");
|
||||
const syaratJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/syarat-ketentuan.json");
|
||||
const dokumenJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/dokumen-jadwal.json");
|
||||
const daftarJadwalJson = loadJsonData("kesehatan/jadwal-kegiatan/pendaftaran-jadwal.json");
|
||||
|
||||
export async function seedJadwalKegiatan() {
|
||||
console.log("Seeding Informasi Jadwal Kegiatan...");
|
||||
for (const item of infoJadwalJson) {
|
||||
await prisma.informasiJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
tanggal: item.tanggal,
|
||||
waktu: item.waktu,
|
||||
lokasi: item.lokasi,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
tanggal: item.tanggal,
|
||||
waktu: item.waktu,
|
||||
lokasi: item.lokasi,
|
||||
},
|
||||
});
|
||||
console.log(` Informasi: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Deskripsi Jadwal Kegiatan...");
|
||||
for (const item of deskJadwalJson) {
|
||||
await prisma.deskripsiJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { deskripsi: item.deskripsi },
|
||||
create: { id: item.id, deskripsi: item.deskripsi },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Layanan Jadwal Kegiatan...");
|
||||
for (const item of layananJadwalJson) {
|
||||
await prisma.layananJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Syarat & Ketentuan Jadwal...");
|
||||
for (const item of syaratJadwalJson) {
|
||||
await prisma.syaratKetentuanJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Dokumen Jadwal Kegiatan...");
|
||||
for (const item of dokumenJadwalJson) {
|
||||
await prisma.dokumenJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: { content: item.content },
|
||||
create: { id: item.id, content: item.content },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seeding Pendaftaran Jadwal Kegiatan...");
|
||||
for (const item of daftarJadwalJson) {
|
||||
await prisma.pendaftaranJadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
name: item.name,
|
||||
tanggal: item.tanggal,
|
||||
namaOrangtua: item.namaOrtu,
|
||||
nomor: item.nomor,
|
||||
alamat: item.alamat,
|
||||
catatan: item.catatan,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
tanggal: item.tanggal,
|
||||
namaOrangtua: item.namaOrtu,
|
||||
nomor: item.nomor,
|
||||
alamat: item.alamat,
|
||||
catatan: item.catatan,
|
||||
},
|
||||
});
|
||||
console.log(` Pendaftaran: ${item.name}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Jadwal Kegiatan...");
|
||||
for (const item of jadwalJson) {
|
||||
await prisma.jadwalKegiatan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
content: item.content,
|
||||
informasiJadwalKegiatanId: item.informasiJadwalKegiatanId,
|
||||
deskripsiJadwalKegiatanId: item.deskripsiJadwalKegiatanId,
|
||||
layananJadwalKegiatanId: item.layananJadwalKegiatanId,
|
||||
syaratKetentuanJadwalKegiatanId: item.syaratKetentuanJadwalKegiatanId,
|
||||
dokumenJadwalKegiatanId: item.dokumenJadwalKegiatanId,
|
||||
pendaftaranJadwalKegiatanId: item.pendaftaranJadwalKegiatanId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
informasiJadwalKegiatanId: item.informasiJadwalKegiatanId,
|
||||
deskripsiJadwalKegiatanId: item.deskripsiJadwalKegiatanId,
|
||||
layananJadwalKegiatanId: item.layananJadwalKegiatanId,
|
||||
syaratKetentuanJadwalKegiatanId: item.syaratKetentuanJadwalKegiatanId,
|
||||
dokumenJadwalKegiatanId: item.dokumenJadwalKegiatanId,
|
||||
pendaftaranJadwalKegiatanId: item.pendaftaranJadwalKegiatanId,
|
||||
},
|
||||
});
|
||||
console.log(` Jadwal Kegiatan seeded`);
|
||||
}
|
||||
|
||||
console.log("Jadwal Kegiatan seed selesai");
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import kontakDaruratJson from "../../../data/kesehatan/kontak-darurat/kontak-darurat.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
const kontakDaruratJson = loadJsonData("kesehatan/kontak-darurat/kontak-darurat.json");
|
||||
|
||||
export async function seedKontakDarurat() {
|
||||
console.log("🔄 Seeding Kontak Darurat...");
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import penangananDaruratJson from "../../../data/kesehatan/penanganan-darurat/penganan-darurat.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const penangananDaruratJson = loadJsonData("kesehatan/penanganan-darurat/penganan-darurat.json");
|
||||
|
||||
export async function seedPenangananDarurat() {
|
||||
console.log("🔄 Seeding Penanganan Darurat...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import posyanduJson from "../../../data/kesehatan/posyandu/posyandu.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const posyanduJson = loadJsonData("kesehatan/posyandu/posyandu.json");
|
||||
|
||||
export async function seedPosyandu() {
|
||||
console.log("🔄 Seeding Posyandu...");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import programKesehatanJson from "../../../data/kesehatan/program-kesehatan/program-kesehatan.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const programKesehatanJson = loadJsonData("kesehatan/program-kesehatan/program-kesehatan.json");
|
||||
|
||||
export async function seedProgramKesehatan() {
|
||||
for (const p of programKesehatanJson) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import puskesmasJson from "../../../data/kesehatan/puskesmas/puskesmas.json";
|
||||
import kontakPuskesmasJson from "../../../data/kesehatan/puskesmas/kontak-puskesmas/kontak.json";
|
||||
import jamPuskesmasJson from "../../../data/kesehatan/puskesmas/jam-puskesmas/jam.json";
|
||||
import { loadJsonData } from "../../../load-json";
|
||||
|
||||
const puskesmasJson = loadJsonData("kesehatan/puskesmas/puskesmas.json");
|
||||
const kontakPuskesmasJson = loadJsonData("kesehatan/puskesmas/kontak-puskesmas/kontak.json");
|
||||
const jamPuskesmasJson = loadJsonData("kesehatan/puskesmas/jam-puskesmas/jam.json");
|
||||
|
||||
export async function seedPuskesmas() {
|
||||
console.log("🔄 Seeding Kontak Puskesmas...");
|
||||
|
||||
32
prisma/_seeder_list/kesehatan/seed_grafik_kepuasan.ts
Normal file
32
prisma/_seeder_list/kesehatan/seed_grafik_kepuasan.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const grafikKepuasanJson = loadJsonData("kesehatan/grafik-kepuasan/grafik-kepuasan.json");
|
||||
|
||||
export async function seedGrafikKepuasan() {
|
||||
console.log("Seeding Grafik Kepuasan...");
|
||||
|
||||
for (const item of grafikKepuasanJson) {
|
||||
await prisma.grafikKepuasan.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
penyakit: item.penyakit,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
penyakit: item.penyakit,
|
||||
},
|
||||
});
|
||||
console.log(` Grafik Kepuasan: ${item.nama}`);
|
||||
}
|
||||
|
||||
console.log("Grafik Kepuasan seed selesai");
|
||||
}
|
||||
70
prisma/_seeder_list/kesehatan/seed_kelahiran_kematian.ts
Normal file
70
prisma/_seeder_list/kesehatan/seed_kelahiran_kematian.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { loadJsonData } from "../../load-json";
|
||||
|
||||
const kelahiranJson = loadJsonData("kesehatan/kelahiran/kelahiran.json");
|
||||
const kematianJson = loadJsonData("kesehatan/kematian/kematian.json");
|
||||
const dataKematianKelahiranJson = loadJsonData("kesehatan/kematian-kelahiran/data-kematian-kelahiran.json");
|
||||
|
||||
export async function seedKelahiranKematian() {
|
||||
console.log("Seeding Kelahiran...");
|
||||
for (const item of kelahiranJson) {
|
||||
await prisma.kelahiran.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
},
|
||||
});
|
||||
console.log(` Kelahiran: ${item.nama}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Kematian...");
|
||||
for (const item of kematianJson) {
|
||||
await prisma.kematian.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
penyebab: item.penyebab,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
nama: item.nama,
|
||||
tanggal: new Date(item.tanggal),
|
||||
jenisKelamin: item.jenisKelamin,
|
||||
alamat: item.alamat,
|
||||
penyebab: item.penyebab,
|
||||
},
|
||||
});
|
||||
console.log(` Kematian: ${item.nama}`);
|
||||
}
|
||||
|
||||
console.log("Seeding Data Kematian-Kelahiran...");
|
||||
for (const item of dataKematianKelahiranJson) {
|
||||
await prisma.dataKematian_Kelahiran.upsert({
|
||||
where: { id: item.id },
|
||||
update: {
|
||||
kematianId: item.kematianId,
|
||||
kelahiranId: item.kelahiranId,
|
||||
},
|
||||
create: {
|
||||
id: item.id,
|
||||
kematianId: item.kematianId,
|
||||
kelahiranId: item.kelahiranId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Kelahiran & Kematian seed selesai");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user