diff --git a/.claude/commands/deploy-stg.md b/.claude/commands/deploy-stg.md index 1e9cd24e..9d2a523b 100644 --- a/.claude/commands/deploy-stg.md +++ b/.claude/commands/deploy-stg.md @@ -1,8 +1,8 @@ # deploy-stg -Deploy ke staging environment secara penuh: version bump, cek migrasi, commit, push, trigger GitHub workflow (publish + re-pull), dan verifikasi versi. +Deploy ke staging environment secara penuh menggunakan MCP server `deploy-stg`. -**Repo GitHub:** `bipproductbali/desa-darmasaba` +**Repo GitHub:** `bipprojectbali/desa-darmasaba` **Branch stg:** `stg` **STG URL:** `https://desa-darmasaba-stg.wibudev.com` @@ -10,37 +10,30 @@ Deploy ke staging environment secara penuh: version bump, cek migrasi, commit, p ## Alur Eksekusi -### Langkah 0 — Cek /api/version -Sebelum apapun, pastikan endpoint `/api/version` sudah ada: +### Langkah 0 — Cek /api/version endpoint +Pastikan endpoint `/api/version` sudah ada di API: ```bash grep -n '"/version"' src/app/api/\[\[...slugs\]\]/route.ts ``` -Jika belum ada, tambahkan langsung ke main API group (referensi dari package.json via `fs.readFile`). +Jika belum ada, tambahkan ke main API group yang membaca `version` dari `package.json`. --- ### Langkah 1 — Version Bump -Baca versi saat ini dari `package.json`, bump patch version (+1), lalu tulis ulang: -```bash -# Baca versi saat ini -node -e "const p=require('./package.json'); const [maj,min,pat]=p.version.split('.').map(Number); console.log(maj+'.'+min+'.'+(pat+1))" -``` -Update `package.json` dengan versi baru. Simpan versi baru sebagai `$NEW_VERSION`. +Gunakan MCP tool `bump_version` (server: `deploy-stg`). + +Tool otomatis baca `package.json`, increment patch (+1), tulis kembali. +Catat `new_version` dari response — akan dipakai di Langkah 5 dan 6. --- -### Langkah 2 — Cek Prisma Migration +### Langkah 2 — Cek & Buat Migration -```bash -bunx prisma migrate status -``` +Gunakan MCP tool `check_migrations` (server: `deploy-stg`). -- Jika output mengandung kata `pending` atau `drift` → buat migrasi baru: - ```bash - bunx prisma migrate dev --name bump-stg- - ``` -- Jika sudah up-to-date → lanjut ke langkah berikutnya. +- Jika `needs_migration: true` → jalankan `create_migration` dengan `name: bump-stg-` +- Jika `is_up_to_date: true` → lanjut ke Langkah 3 --- @@ -50,126 +43,78 @@ bunx prisma migrate status bun run build ``` -Jika build gagal, **stop** dan perbaiki error dulu sebelum melanjutkan deploy. +Jika build gagal → **stop**, perbaiki error sebelum melanjutkan. --- -### Langkah 4 — Commit +### Langkah 4 — Commit & Push ke stg -Stage semua perubahan dan commit: -```bash -git add package.json -git add prisma/migrations/ # jika ada migrasi baru -git commit -m "chore: bump version to $NEW_VERSION for stg deploy" -``` +Gunakan MCP tool `commit_and_push_stg` (server: `deploy-stg`). + +Tool otomatis stage `package.json` + `prisma/migrations/`, commit dengan message yang menyertakan versi baru, lalu push ke branch `stg` menggunakan `GH_TOKEN`. --- -### Langkah 5 — Push ke origin/stg +### Langkah 5 — Trigger publish.yml -```bash -git push origin HEAD:stg -``` +Gunakan MCP tool `trigger_publish` (server: `deploy-stg`): +- `stack_env`: `stg` (default) +- `tag`: nilai `new_version` dari Langkah 1 (sama persis dengan versi di `package.json`) -Tunggu push selesai sebelum trigger workflow. +Tool mengembalikan `run_id`. Poll dengan `watch_workflow_run` setiap 30 detik hingga `status == "completed"`: +- `conclusion == "success"` → lanjut ke Langkah 6 +- `conclusion != "success"` → **stop**, tampilkan error, jangan lanjut ke re-pull --- -### Langkah 6 — Trigger publish.yml +### Langkah 6 — Trigger re-pull.yml -**Input 1:** `stack_env` = `stg` -**Input 2:** `tag` = versi dari package.json (contoh: `0.1.25`) +Setelah publish berhasil, gunakan MCP tool `trigger_repull` (server: `deploy-stg`): +- `stack_name`: otomatis dari env `STACK_NAME` (tidak perlu diisi manual) +- `stack_env`: `stg` -```bash -gh workflow run publish.yml \ - --repo bipprojectbali/desa-darmasaba \ - --ref stg \ - -f stack_env=stg \ - -f tag=$NEW_VERSION -``` - -Tunggu 5 detik lalu dapatkan run ID: -```bash -sleep 5 -RUN_ID=$(gh run list \ - --workflow=publish.yml \ - --repo bipprojectbali/desa-darmasaba \ - --limit 1 \ - --json databaseId \ - -q '.[0].databaseId') -``` - -Monitor sampai selesai: -```bash -gh run watch $RUN_ID --repo bipprojectbali/desa-darmasaba -``` - -Jika publish **gagal** → **stop**, jangan lanjut ke re-pull. +Stack yang di-deploy: `-stg`. Poll dengan `watch_workflow_run` setiap 30 detik hingga `status == "completed"`. --- -### Langkah 7 — Trigger re-pull.yml +### Langkah 7 — Verifikasi Versi -Setelah publish berhasil, trigger re-pull: +Gunakan MCP tool `check_stg_version` (server: `deploy-stg`): +- `wait_seconds`: `30` (tunggu container siap) -**Input 1:** `stack_env` = `stg` -**Input 2:** `stack_name` = `desa-darmasaba` → stack yang di-deploy: `desa-darmasaba-stg` - -```bash -gh workflow run re-pull.yml \ - --repo bipprojectbali/desa-darmasaba \ - --ref main \ - -f stack_name=desa-darmasaba \ - -f stack_env=stg -``` - -Tunggu 5 detik lalu monitor: -```bash -sleep 5 -REPULL_ID=$(gh run list \ - --workflow=re-pull.yml \ - --repo bipprojectbali/desa-darmasaba \ - --limit 1 \ - --json databaseId \ - -q '.[0].databaseId') -gh run watch $REPULL_ID --repo bipprojectbali/desa-darmasaba -``` +Tool otomatis fetch `BASE_URL/api/version` dan bandingkan dengan versi lokal. +- `match: true` → **Deploy berhasil!** +- `match: false` → cek container logs di Portainer atau jalankan `gh run view --repo bipprojectbali/desa-darmasaba --log` --- -### Langkah 8 — Verifikasi Versi +## Ringkasan MCP Tools -Bandingkan versi di stg dengan versi lokal: - -```bash -# Versi lokal -LOCAL_VER=$(node -e "console.log(require('./package.json').version)") - -# Versi di STG (tunggu container siap ~30 detik) -sleep 30 -STG_VER=$(curl -s https://desa-darmasaba-stg.wibudev.com/api/version | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).version))") - -echo "Local : $LOCAL_VER" -echo "STG : $STG_VER" -``` - -- Jika `LOCAL_VER == STG_VER` → **Deploy berhasil!** -- Jika berbeda → cek logs container di Portainer atau jalankan `gh run view $REPULL_ID --repo bipprojectbali/desa-darmasaba --log` - ---- +| Tool | Server | Tujuan | +|------|--------|--------| +| `bump_version` | `deploy-stg` | Increment patch version di package.json | +| `check_migrations` | `deploy-stg` | Cek status Prisma migrations | +| `create_migration` | `deploy-stg` | Buat migration baru jika diperlukan | +| `commit_and_push_stg` | `deploy-stg` | Commit + push ke branch stg | +| `trigger_publish` | `deploy-stg` | Trigger publish.yml (build Docker image) | +| `watch_workflow_run` | `deploy-stg` | Poll status workflow run | +| `trigger_repull` | `deploy-stg` | Trigger re-pull.yml (redeploy di Portainer) | +| `check_stg_version` | `deploy-stg` | Bandingkan versi lokal vs STG | ## Ringkasan Workflow Inputs | Workflow | Input | Value | |----------|-------|-------| | `publish.yml` | `stack_env` | `stg` | -| `publish.yml` | `tag` | versi dari `package.json` (e.g. `0.1.25`) | -| `re-pull.yml` | `stack_name` | `desa-darmasaba` | +| `publish.yml` | `tag` | versi dari `package.json` (e.g. `0.1.26`) | +| `publish.yml` | `stack_name` | dari env `STACK_NAME` (opsional) | +| `re-pull.yml` | `stack_name` | dari env `STACK_NAME` (otomatis) | | `re-pull.yml` | `stack_env` | `stg` | ## Catatan - Jangan jalankan `re-pull.yml` jika `publish.yml` belum selesai/berhasil. -- Verifikasi versi dilakukan via `/api/version` (bukan `/api/utils/version`). -- Jika `gh` belum login: `gh auth login`. -- Untuk cek status workflow manual: `gh run list --repo bipprojectbali/desa-darmasaba`. +- Isi `GH_TOKEN` di `.env` sebelum menjalankan deploy (bisa sama dengan `GH_TOKEN`). +- `BASE_URL` di `.env` sudah diset ke `https://desa-darmasaba-stg.wibudev.com`. +- `STACK_NAME` di `.env` diset ke nama stack (e.g. `desa-darmasaba`) — dipakai otomatis oleh `trigger_publish` dan `trigger_repull`. +- Verifikasi versi via `/api/version` (bukan `/api/utils/version`). diff --git a/.claude/mcp/github-actions.mjs b/.claude/mcp/github-actions.mjs index b0b0c7f9..0bb5ed58 100644 --- a/.claude/mcp/github-actions.mjs +++ b/.claude/mcp/github-actions.mjs @@ -1,12 +1,16 @@ #!/usr/bin/env node /** - * MCP Server: GitHub Actions Workflow Tools - * Tools: trigger_publish, trigger_repull, get_workflow_runs, watch_workflow_run + * MCP Server: GitHub Actions Workflow Tools + Deploy-STG Pre/Post Steps + * Tools: trigger_publish, trigger_repull, get_workflow_runs, watch_workflow_run, + * check_migrations, create_migration, bump_version, commit_and_push_stg, check_stg_version */ -import { execSync, spawnSync } from "child_process"; +import { spawnSync } from "child_process"; +import { readFileSync, writeFileSync } from "fs"; +import { join } from "path"; const REPO = "bipprojectbali/desa-darmasaba"; +const CWD = process.cwd(); // --- MCP Protocol Helpers --- @@ -24,8 +28,12 @@ function respondError(id, code, message) { // --- Shell Helper --- -function runCmd(cmd) { - const r = spawnSync("sh", ["-c", cmd], { encoding: "utf-8" }); +function runCmd(cmd, opts = {}) { + const r = spawnSync("sh", ["-c", cmd], { + encoding: "utf-8", + cwd: CWD, + ...opts, + }); if (r.error) return { ok: false, out: r.error.message }; if (r.status !== 0) return { ok: false, out: (r.stderr || r.stdout || "").trim() }; return { ok: true, out: (r.stdout || "").trim() }; @@ -41,21 +49,75 @@ function getLatestRunId(workflow, delaySecs = 3) { // --- Tool Definitions --- const TOOLS = [ + { + name: "check_migrations", + description: "Cek status Prisma migrations. Returns apakah ada pending migrations.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "create_migration", + description: "Buat Prisma migration baru (prisma migrate dev). Jalankan hanya jika check_migrations mendeteksi pending/drift.", + inputSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "Nama migration (e.g. bump-stg-0.1.26)", + }, + }, + required: ["name"], + }, + }, + { + name: "bump_version", + description: "Baca versi dari package.json, increment patch (+1), tulis kembali. Returns old_version dan new_version.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "commit_and_push_stg", + description: "Stage package.json + prisma/migrations, commit, lalu push ke branch stg menggunakan GH_TOKEN.", + inputSchema: { + type: "object", + properties: { + message: { + type: "string", + description: "Commit message. Jika tidak diisi, auto-generate dari versi package.json.", + }, + }, + }, + }, + { + name: "check_stg_version", + description: "Bandingkan versi lokal (package.json) dengan versi di STG (/api/version). Tunggu container siap jika perlu.", + inputSchema: { + type: "object", + properties: { + wait_seconds: { + type: "number", + description: "Detik tunggu sebelum fetch STG (default: 30, untuk memberi waktu container siap).", + }, + }, + }, + }, { name: "trigger_publish", description: - "Trigger publish.yml workflow: build & push Docker image to GHCR. Returns run ID.", + "Trigger publish.yml workflow: build & push Docker image ke GHCR. Returns run ID.", inputSchema: { type: "object", properties: { stack_env: { type: "string", enum: ["dev", "stg", "prod"], - description: "Target environment (branch dengan nama ini akan di-checkout)", + description: "Target environment. Default: stg.", }, tag: { type: "string", - description: "Image tag, biasanya versi dari package.json (e.g. 0.1.25)", + description: "Image tag — harus sama persis dengan versi di package.json (e.g. 0.1.25).", + }, + stack_name: { + type: "string", + description: `Nama stack. Default: nilai env STACK_NAME (${process.env.STACK_NAME || "belum diset"}).`, }, }, required: ["stack_env", "tag"], @@ -64,21 +126,21 @@ const TOOLS = [ { name: "trigger_repull", description: - "Trigger re-pull.yml workflow: redeploy stack di Portainer. Hanya jalankan setelah publish berhasil.", + "Trigger re-pull.yml workflow: redeploy stack di Portainer. Jalankan HANYA setelah publish berhasil.", inputSchema: { type: "object", properties: { stack_name: { type: "string", - description: "Nama stack (e.g. desa-darmasaba). Stack yang di-deploy: -", + description: `Nama stack (e.g. desa-darmasaba). Stack yang di-deploy: -. Default: nilai env STACK_NAME (${process.env.STACK_NAME || "belum diset"}).`, }, stack_env: { type: "string", enum: ["dev", "stg", "prod"], - description: "Target environment", + description: "Target environment.", }, }, - required: ["stack_name", "stack_env"], + required: ["stack_env"], }, }, { @@ -108,7 +170,7 @@ const TOOLS = [ properties: { run_id: { type: "number", - description: "ID workflow run (dapat dari get_workflow_runs)", + description: "ID workflow run (dapat dari trigger_publish / trigger_repull / get_workflow_runs)", }, }, required: ["run_id"], @@ -118,16 +180,164 @@ const TOOLS = [ // --- Tool Handlers --- +function handleCheckMigrations() { + const r = runCmd("bunx prisma migrate status 2>&1", { timeout: 30000 }); + const out = r.out || ""; + const hasPending = + out.includes("following migration(s) have not yet been applied") || + out.includes("drift") || + out.includes("pending"); + const isUpToDate = + out.includes("Database schema is up to date") || + out.includes("up to date"); + return { + ok: true, + needs_migration: hasPending && !isUpToDate, + is_up_to_date: isUpToDate, + output: out, + }; +} + +function handleCreateMigration(args) { + const { name } = args; + const r = runCmd( + `echo "" | bunx prisma migrate dev --name ${name} --skip-generate 2>&1`, + { timeout: 120000 } + ); + return { + ok: r.ok, + output: r.out, + error: r.ok ? undefined : r.out, + }; +} + +function handleBumpVersion() { + const pkgPath = join(CWD, "package.json"); + let pkg; + try { + pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + } catch (e) { + return { ok: false, error: `Gagal baca package.json: ${e.message}` }; + } + const oldVersion = pkg.version; + const [maj, min, pat] = oldVersion.split(".").map(Number); + const newVersion = `${maj}.${min}.${pat + 1}`; + pkg.version = newVersion; + try { + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + } catch (e) { + return { ok: false, error: `Gagal tulis package.json: ${e.message}` }; + } + return { ok: true, old_version: oldVersion, new_version: newVersion }; +} + +function handleCommitAndPushStg(args) { + const { message } = args || {}; + + // Baca versi untuk auto commit message + let version = "unknown"; + try { + const pkg = JSON.parse(readFileSync(join(CWD, "package.json"), "utf-8")); + version = pkg.version; + } catch {} + + const commitMsg = message || `chore: bump version to ${version} for stg deploy`; + + // Stage file + runCmd("git add package.json 2>&1"); + runCmd("git add prisma/migrations/ 2>&1"); + + // Cek apakah ada yang di-stage + const statusR = runCmd("git diff --cached --name-only 2>&1"); + if (!statusR.out.trim()) { + return { ok: false, error: "Tidak ada perubahan yang di-stage untuk di-commit." }; + } + + // Commit + const commitR = runCmd(`git commit -m "${commitMsg}" 2>&1`); + if (!commitR.ok) return { ok: false, step: "commit", error: commitR.out }; + + // Push ke stg menggunakan GH_TOKEN jika tersedia + const token = process.env.GH_TOKEN; + const pushCmd = token + ? `git push https://x-access-token:${token}@github.com/${REPO}.git HEAD:stg 2>&1` + : `git push origin HEAD:stg 2>&1`; + const pushR = runCmd(pushCmd, { timeout: 60000 }); + if (!pushR.ok) return { ok: false, step: "push", error: pushR.out }; + + return { + ok: true, + message: `Berhasil commit "${commitMsg}" dan push ke stg`, + version, + }; +} + +function handleCheckStgVersion(args) { + const waitSecs = (args && args.wait_seconds) || 30; + const baseUrl = process.env.BASE_URL; + if (!baseUrl) { + return { ok: false, error: "BASE_URL tidak ada di environment — isi di .env" }; + } + + // Baca versi lokal + let localVersion; + try { + const pkg = JSON.parse(readFileSync(join(CWD, "package.json"), "utf-8")); + localVersion = pkg.version; + } catch (e) { + return { ok: false, error: `Gagal baca package.json: ${e.message}` }; + } + + // Tunggu container siap + if (waitSecs > 0) { + runCmd(`sleep ${waitSecs}`); + } + + // Fetch versi dari STG + const r = runCmd(`curl -sf --max-time 10 "${baseUrl}/api/version" 2>&1`); + if (!r.ok) { + return { + ok: false, + error: `Gagal fetch ${baseUrl}/api/version: ${r.out}`, + local_version: localVersion, + }; + } + + let stgVersion; + try { + stgVersion = JSON.parse(r.out).version; + } catch { + return { + ok: false, + error: `Gagal parse response: ${r.out}`, + local_version: localVersion, + }; + } + + const match = localVersion === stgVersion; + return { + ok: true, + local_version: localVersion, + stg_version: stgVersion, + match, + status: match + ? "✓ DEPLOY BERHASIL — versi STG sudah sesuai" + : "✗ VERSI BERBEDA — cek container logs di Portainer", + }; +} + function handleTriggerPublish(args) { - const { stack_env, tag } = args; - const triggerCmd = `gh workflow run publish.yml --repo ${REPO} --ref ${stack_env} -f stack_env=${stack_env} -f tag=${tag}`; + const { stack_env = "stg", tag, stack_name } = args; + const resolvedStackName = stack_name || process.env.STACK_NAME; + let triggerCmd = `gh workflow run publish.yml --repo ${REPO} --ref ${stack_env} -f stack_env=${stack_env} -f tag=${tag}`; + if (resolvedStackName) triggerCmd += ` -f stack_name=${resolvedStackName}`; const r = runCmd(triggerCmd); if (!r.ok) return { ok: false, error: r.out }; const runId = getLatestRunId("publish.yml"); return { ok: true, - message: `Workflow publish.yml berhasil di-trigger (env=${stack_env}, tag=${tag})`, + message: `Workflow publish.yml di-trigger (env=${stack_env}, tag=${tag}${resolvedStackName ? `, stack=${resolvedStackName}` : ""})`, run_id: runId ? Number(runId) : null, monitor_hint: runId ? `Gunakan watch_workflow_run dengan run_id: ${runId}` @@ -136,7 +346,12 @@ function handleTriggerPublish(args) { } function handleTriggerRepull(args) { - const { stack_name, stack_env } = args; + const resolvedStackName = args.stack_name || process.env.STACK_NAME; + const { stack_env } = args; + if (!resolvedStackName) { + return { ok: false, error: "stack_name wajib diisi atau set env STACK_NAME di .mcp.json" }; + } + const stack_name = resolvedStackName; const triggerCmd = `gh workflow run re-pull.yml --repo ${REPO} --ref main -f stack_name=${stack_name} -f stack_env=${stack_env}`; const r = runCmd(triggerCmd); if (!r.ok) return { ok: false, error: r.out }; @@ -144,7 +359,7 @@ function handleTriggerRepull(args) { const runId = getLatestRunId("re-pull.yml"); return { ok: true, - message: `Workflow re-pull.yml berhasil di-trigger (stack=${stack_name}-${stack_env})`, + message: `Workflow re-pull.yml di-trigger (stack=${stack_name}-${stack_env})`, run_id: runId ? Number(runId) : null, monitor_hint: runId ? `Gunakan watch_workflow_run dengan run_id: ${runId}` @@ -199,7 +414,7 @@ function handleMessage(msg) { respond(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, - serverInfo: { name: "github-actions", version: "1.0.0" }, + serverInfo: { name: "deploy-stg", version: "2.0.0" }, }); return; } @@ -220,8 +435,13 @@ function handleMessage(msg) { const { name, arguments: args = {} } = params; let result; - if (name === "trigger_publish") result = handleTriggerPublish(args); - else if (name === "trigger_repull") result = handleTriggerRepull(args); + if (name === "check_migrations") result = handleCheckMigrations(); + else if (name === "create_migration") result = handleCreateMigration(args); + else if (name === "bump_version") result = handleBumpVersion(); + else if (name === "commit_and_push_stg") result = handleCommitAndPushStg(args); + else if (name === "check_stg_version") result = handleCheckStgVersion(args); + else if (name === "trigger_publish") result = handleTriggerPublish(args); + else if (name === "trigger_repull") result = handleTriggerRepull(args); else if (name === "get_workflow_runs") result = handleGetWorkflowRuns(args); else if (name === "watch_workflow_run") result = handleWatchWorkflowRun(args); else { diff --git a/.mcp.json b/.mcp.json index 69e1f5b2..f4d93544 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,15 +1,13 @@ { "mcpServers": { - "github": { - "command": "sh", - "args": [ - "-c", - "GITHUB_PERSONAL_ACCESS_TOKEN=$(gh auth token) npx -y @modelcontextprotocol/server-github" - ] - }, - "github-actions": { + "deploy-stg": { "command": "node", - "args": [".claude/mcp/github-actions.mjs"] + "args": ["--env-file=.env", ".claude/mcp/github-actions.mjs"], + "env": { + "GH_TOKEN": "${GH_TOKEN}", + "BASE_URL": "${BASE_URL}", + "STACK_NAME": "${STACK_NAME}" + } } } } diff --git a/MIND/PLAN/migrate-kategori-produk-umkm.md b/MIND/PLAN/migrate-kategori-produk-umkm.md new file mode 100644 index 00000000..6861d936 --- /dev/null +++ b/MIND/PLAN/migrate-kategori-produk-umkm.md @@ -0,0 +1,90 @@ +# Plan: Migrasi KategoriProduk → KategoriProdukUmkm untuk UMKM + +## Tujuan +Ganti relasi kategori di model `Umkm` dan seluruh CRUD kategori UMKM agar menggunakan model `KategoriProdukUmkm` (bukan `KategoriProduk`). Model `KategoriProduk` tetap dipertahankan untuk `PasarDesa` dan `KategoriToPasar`. + +## Analisis Kondisi Saat Ini + +### Schema Prisma +- `Umkm` → `kategori KategoriProduk @relation(...)` + `kategoriId String` +- `KategoriProdukUmkm` punya relasi `Umkm[]` tapi Umkm belum punya FK ke sana (schema tidak konsisten) +- `KategoriProduk` dipakai oleh: `Umkm[]`, `PasarDesa[]`, `KategoriToPasar[]` + +### File yang Perlu Diubah +1. `prisma/schema.prisma` — ubah relasi Umkm +2. `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts` — ganti semua `prisma.kategoriProduk` → `prisma.kategoriProdukUmkm` +3. `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/create.ts` — relasi kategori di PasarDesa tidak berubah (masih `KategoriProduk`) +4. `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/produk/findMany.ts` — include `kategoriProduk` di PasarDesa tidak berubah +5. `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts` — groupBy `kategoriId` di Umkm perlu include model baru +6. `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` — state/form kategori UMKM +7. Admin pages (list, create, edit) — tidak perlu banyak perubahan (UI tetap sama) + +--- + +## Langkah-Langkah + +### Fase 1 — Schema Prisma +**File:** `prisma/schema.prisma` + +1. Di model `Umkm` (line ~2435): + - Ubah `kategori KategoriProduk @relation(fields: [kategoriId], references: [id])` → `kategori KategoriProdukUmkm @relation(fields: [kategoriId], references: [id])` + - Field `kategoriId String` tetap sama (nama field tidak perlu berubah) + +2. Di model `KategoriProduk` (line ~1453): + - Hapus baris `Umkm Umkm[]` (UMKM tidak lagi relasi ke sini) + +3. Model `KategoriProdukUmkm` sudah punya `Umkm Umkm[]` — tidak perlu diubah + +### Fase 2 — Database Migration +```bash +bunx prisma migrate dev --name migrate-umkm-kategori-to-kategori-produk-umkm +``` +- Migration akan: ubah FK constraint di tabel `Umkm` dari `KategoriProduk` → `KategoriProdukUmkm` +- **Perhatian:** Data lama di `kategoriId` merujuk ke `KategoriProduk`. Perlu seed/migrasi data atau set nullable dulu. + +> **Strategi migrasi data:** +> - Buat `kategoriId` di `Umkm` menjadi nullable sementara (`String?`) +> - Jalankan migration +> - Seed `KategoriProdukUmkm` dengan data yang sama seperti `KategoriProduk` +> - Update data Umkm yang ada agar `kategoriId` menunjuk ke `KategoriProdukUmkm` yang sesuai +> - Set kembali `kategoriId` menjadi required (`String`) + +### Fase 3 — API Handler: CRUD Kategori Produk UMKM +**File:** `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts` + +Ganti semua `prisma.kategoriProduk` → `prisma.kategoriProdukUmkm`: +- `/find-many-all` → `prisma.kategoriProdukUmkm.findMany()` +- `/find-many` → `prisma.kategoriProdukUmkm.findMany()` +- `/create` → `prisma.kategoriProdukUmkm.create()` +- `PUT /:id` → `prisma.kategoriProdukUmkm.update()` +- `DELETE /del/:id` → `prisma.kategoriProdukUmkm.update()` + +### Fase 4 — API Handler: Dashboard KPI +**File:** `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts` + +- Bagian `groupBy kategoriId` untuk Umkm: ubah include/join agar ambil nama dari `KategoriProdukUmkm` bukan `KategoriProduk` + +### Fase 5 — State Management +**File:** `src/app/admin/(dashboard)/_state/ekonomi/umkm/umkm.ts` + +- Pastikan tipe data / form state kategori UMKM masih sesuai (field name tidak berubah, hanya model di backend) + +### Fase 6 — Build & Verify +```bash +bun run tsc --noEmit # Type check +bun run build # Full build +``` + +--- + +## Yang TIDAK Berubah +- `KategoriProduk` model tetap ada (masih digunakan `PasarDesa` dan `KategoriToPasar`) +- Relasi `PasarDesa.kategoriProdukId → KategoriProduk` tidak berubah +- Admin UI pages (kategori-produk/page.tsx, create, edit) — logika UI tidak berubah, hanya backend model berbeda +- API route path tetap sama (`/api/ekonomi/kategoriproduk/*`) + +--- + +## Risiko +- **Data migration:** Umkm yang ada punya `kategoriId` yang merujuk ke `KategoriProduk`. Perlu migrasi data atau data akan hilang relasi. +- **Solusi:** Buat `KategoriProdukUmkm` dengan data yang sama, lalu update `Umkm.kategoriId` via SQL migration script. diff --git a/MIND/PLAN/task-migrate-kategori-produk-umkm.md b/MIND/PLAN/task-migrate-kategori-produk-umkm.md new file mode 100644 index 00000000..557672e4 --- /dev/null +++ b/MIND/PLAN/task-migrate-kategori-produk-umkm.md @@ -0,0 +1,12 @@ +# Task: Migrasi KategoriProduk → KategoriProdukUmkm + +## Progress +- [x] Phase 1: Schema Update (`prisma/schema.prisma`) +- [x] Phase 2: Data Migration (Manual SQL/Script) +- [x] Phase 3: Update API CRUD UMKM Kategori +- [x] Phase 4: Update KPI Dashboard UMKM +- [x] Phase 5: Verification & Build + +## Notes +- `KategoriProduk` tetap dipertahankan untuk `PasarDesa`. +- `KategoriProdukUmkm` akan digunakan secara eksklusif oleh `Umkm`. diff --git a/MIND/SUMMARY/migrate-kategori-produk-umkm-summary.md b/MIND/SUMMARY/migrate-kategori-produk-umkm-summary.md new file mode 100644 index 00000000..ee924378 --- /dev/null +++ b/MIND/SUMMARY/migrate-kategori-produk-umkm-summary.md @@ -0,0 +1,26 @@ +# Summary: Migrasi KategoriProduk → KategoriProdukUmkm + +## Perubahan yang Dilakukan +1. **Schema Prisma**: + - Memisahkan model kategori untuk `Umkm` dan `PasarDesa`. + - `Umkm` sekarang menggunakan `KategoriProdukUmkm`. + - `PasarDesa` tetap menggunakan `KategoriProduk`. + - Menghapus relasi `Umkm` dari model `KategoriProduk`. +2. **Database**: + - Menjalankan `prisma db push` untuk memperbarui tabel di PostgreSQL. + - Menyiapkan dan menguji script migrasi data (tabel saat ini kosong, namun script sudah diverifikasi). +3. **Backend API**: + - Mengubah `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts` agar menggunakan `prisma.kategoriProdukUmkm`. + - Memperbarui logic KPI dashboard di `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts` untuk menggunakan model kategori yang tepat berdasarkan konteks (UMKM vs Penjualan). +4. **Validasi**: + - Berhasil menjalankan `bun run build` tanpa error TypeScript baru. + +## Dampak +- Admin UMKM sekarang memiliki manajemen kategori yang terisolasi dari PasarDesa. +- Tidak ada perubahan pada UI karena path API dan struktur data tetap sama. +- Kompabilitas data tetap terjaga karena relasi menggunakan ID yang sama. + +## File Terkait +- `prisma/schema.prisma` +- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts` +- `src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts` diff --git a/package.json b/package.json index 8c65ce06..a2ac8f48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "desa-darmasaba", - "version": "0.1.32", + "version": "0.1.33", "private": true, "scripts": { "dev": "next dev", diff --git a/prisma/_seeder_list/ekonomi/seed_umkm.ts b/prisma/_seeder_list/ekonomi/seed_umkm.ts index 0dfeda8a..58a53ecd 100644 --- a/prisma/_seeder_list/ekonomi/seed_umkm.ts +++ b/prisma/_seeder_list/ekonomi/seed_umkm.ts @@ -1,5 +1,13 @@ import prisma from "@/lib/prisma"; +const kategoriUmkmData = [ + { id: "4b95bge6-012e-5ged-9552-4d8g65d44959", nama: "Makanan" }, + { id: "5c06chf7-123f-6hfe-0663-5e9h76e55060", nama: "Minuman" }, + { id: "5c06chf7-123f-7igd-0663-5e9h76e55060", nama: "Sembako" }, + { id: "5c06chf7-123f-8jhe-0663-5e9h76e55060", nama: "Sayur Mayur" }, + { id: "5c06chf7-123f-9kif-0663-5e9h76e55060", nama: "Protein Hewani" }, +]; + export const umkmData = [ { id: "umkm-1", @@ -40,6 +48,15 @@ export const umkmData = [ ]; export async function seedUmkm() { + console.log("🔄 Seeding Kategori Produk UMKM..."); + for (const k of kategoriUmkmData) { + await prisma.kategoriProdukUmkm.upsert({ + where: { id: k.id }, + update: { nama: k.nama }, + create: { id: k.id, nama: k.nama }, + }); + } + console.log("🔄 Seeding UMKM..."); for (const u of umkmData) { await prisma.umkm.upsert({ diff --git a/prisma/migrations/20260427222710_rename_kategori_produk_umkm_table/migration.sql b/prisma/migrations/20260427222710_rename_kategori_produk_umkm_table/migration.sql new file mode 100644 index 00000000..8dd32b6c --- /dev/null +++ b/prisma/migrations/20260427222710_rename_kategori_produk_umkm_table/migration.sql @@ -0,0 +1,57 @@ +-- Create KategoriProdukUmkm table if it doesn't exist +-- (renames existing kategori_produk_umkm if present, otherwise creates fresh) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'kategori_produk_umkm' + ) THEN + ALTER TABLE "kategori_produk_umkm" RENAME TO "KategoriProdukUmkm"; + ELSIF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'KategoriProdukUmkm' + ) THEN + CREATE TABLE "KategoriProdukUmkm" ( + "id" TEXT NOT NULL, + "nama" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + CONSTRAINT "KategoriProdukUmkm_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- Seed KategoriProdukUmkm: copy from KategoriProduk where referenced by Umkm +-- This ensures existing Umkm.kategoriId values exist in the new table +INSERT INTO "KategoriProdukUmkm" ("id", "nama", "createdAt", "updatedAt", "deletedAt", "isActive") +SELECT DISTINCT kp.id, kp.nama, kp."createdAt", kp."updatedAt", kp."deletedAt", kp."isActive" +FROM "KategoriProduk" kp +WHERE kp.id IN (SELECT DISTINCT "kategoriId" FROM "Umkm") + AND NOT EXISTS (SELECT 1 FROM "KategoriProdukUmkm" WHERE id = kp.id); + +-- Update Umkm FK: drop old FK pointing to KategoriProduk, add new one to KategoriProdukUmkm +DO $$ +DECLARE + fk_target TEXT; +BEGIN + SELECT ccu.table_name INTO fk_target + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' + AND tc.table_name = 'Umkm' + AND kcu.column_name = 'kategoriId' + LIMIT 1; + + IF fk_target = 'KategoriProduk' THEN + ALTER TABLE "Umkm" DROP CONSTRAINT "Umkm_kategoriId_fkey"; + ALTER TABLE "Umkm" ADD CONSTRAINT "Umkm_kategoriId_fkey" + FOREIGN KEY ("kategoriId") REFERENCES "KategoriProdukUmkm"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f4c3be9d..5faf634b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1459,7 +1459,6 @@ model KategoriProduk { isActive Boolean @default(true) KategoriToPasar KategoriToPasar[] PasarDesa PasarDesa[] - Umkm Umkm[] } model KategoriToPasar { @@ -2424,23 +2423,34 @@ model MusikDesa { } model Umkm { - id String @id @default(cuid()) + id String @id @default(cuid()) nama String pemilik String deskripsi String? alamat String? kontak String? - image FileStorage? @relation("UmkmImage", fields: [imageId], references: [id]) + image FileStorage? @relation("UmkmImage", fields: [imageId], references: [id]) imageId String? - kategori KategoriProduk @relation(fields: [kategoriId], references: [id]) + kategori KategoriProdukUmkm @relation(fields: [kategoriId], references: [id]) kategoriId String - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt deletedAt DateTime? produk PasarDesa[] } +model KategoriProdukUmkm { + id String @id @default(cuid()) + nama String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + isActive Boolean @default(true) + Umkm Umkm[] +} + + model PenjualanProduk { id String @id @default(cuid()) produk PasarDesa @relation(fields: [produkId], references: [id]) diff --git a/src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts b/src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts index 1fc1fbce..9f3182d2 100644 --- a/src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts +++ b/src/app/api/[[...slugs]]/_lib/ekonomi/umkm/dashboard/kpi.ts @@ -86,7 +86,7 @@ async function umkmDashboardKpi(context: Context) { }); if (kategoriTerbanyakUmkm.length > 0) { - const kategori = await prisma.kategoriProduk.findUnique({ + const kategori = await prisma.kategoriProdukUmkm.findUnique({ where: { id: kategoriTerbanyakUmkm[0].kategoriId }, select: { nama: true }, }); diff --git a/src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts b/src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts index 4d9393b0..99754537 100644 --- a/src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts +++ b/src/app/api/[[...slugs]]/_lib/ekonomi/umkm/kategori-produk/kategori-produk.ts @@ -6,7 +6,7 @@ const KategoriProduk = new Elysia({ }) .get("/find-many-all", async () => { try { - const data = await prisma.kategoriProduk.findMany({ + const data = await prisma.kategoriProdukUmkm.findMany({ where: { isActive: true, deletedAt: null, @@ -40,13 +40,13 @@ const KategoriProduk = new Elysia({ }; const [data, total] = await Promise.all([ - prisma.kategoriProduk.findMany({ + prisma.kategoriProdukUmkm.findMany({ where, skip, take, orderBy: { createdAt: 'desc' }, }), - prisma.kategoriProduk.count({ where }), + prisma.kategoriProdukUmkm.count({ where }), ]); return { @@ -74,7 +74,7 @@ const KategoriProduk = new Elysia({ }) .post("/create", async ({ body }) => { try { - const data = await prisma.kategoriProduk.create({ + const data = await prisma.kategoriProdukUmkm.create({ data: { nama: body.nama, isActive: true, @@ -100,7 +100,7 @@ const KategoriProduk = new Elysia({ }) .put("/:id", async ({ params, body }) => { try { - const data = await prisma.kategoriProduk.update({ + const data = await prisma.kategoriProdukUmkm.update({ where: { id: params.id }, data: { nama: body.nama, @@ -129,7 +129,7 @@ const KategoriProduk = new Elysia({ }) .delete("/del/:id", async ({ params }) => { try { - const data = await prisma.kategoriProduk.update({ + const data = await prisma.kategoriProdukUmkm.update({ where: { id: params.id }, data: { isActive: false, diff --git a/src/lib/minio.ts b/src/lib/minio.ts index 32712102..343a2cff 100644 --- a/src/lib/minio.ts +++ b/src/lib/minio.ts @@ -1,12 +1,12 @@ import { Client } from "minio"; const minioClient = new Client({ - endPoint: process.env.MINIO_ENDPOINT!, - accessKey: process.env.MINIO_ACCESS_KEY!, - secretKey: process.env.MINIO_SECRET_KEY!, + endPoint: process.env.MINIO_ENDPOINT ?? "localhost", + accessKey: process.env.MINIO_ACCESS_KEY ?? "", + secretKey: process.env.MINIO_SECRET_KEY ?? "", useSSL: process.env.MINIO_USE_SSL === "true", }); -export const MINIO_BUCKET = process.env.MINIO_BUCKET!; +export const MINIO_BUCKET = process.env.MINIO_BUCKET ?? ""; export default minioClient;