# Koperasi Desa Merah Putih — Fase 0 Foundation Design

> **Untuk agentic worker:** SUB-SKILL WAJIB: gunakan `superpowers:writing-plans` untuk menyusun implementation plan dari spec ini, lalu `superpowers:subagent-driven-development` (rekomendasi) atau `superpowers:executing-plans` untuk eksekusi.

**Status:** Disetujui pengguna pada 2026-05-26
**Subdomain target:** `premium.developerkdmp.my.id`
**Lingkungan:** cPanel di `/home/developerkdmpmy/`

---

## 1. Konteks & Lingkup

Aplikasi Koperasi Desa Merah Putih akan dibangun **bertahap modul-per-modul**. Dokumen ini hanya mencakup **Fase 0 — Foundation**: skeleton proyek, identitas & akses, audit log, transaction-safety primitives, dan file storage. Modul bisnis (simpanan, simpan pinjam, POS, inventory, akuntansi, multi-unit usaha, dashboard) akan dibangun di fase berikutnya, masing-masing dengan spec terpisah.

**Target skala:** 5.000–10.000 anggota, single-tenant (1 koperasi), multi unit usaha.

**Goal Fase 0:**
1. Skeleton siap deploy ke cPanel di subdomain `premium.developerkdmp.my.id`.
2. Super admin bisa login, buat user/role/permission, melihat audit log.
3. Primitives `IdempotencyKey`, `Job` queue, `pg_advisory_lock`, dan `FileObject` storage tersedia dan teruji — dipakai modul fase berikutnya.
4. Pattern migrasi: `pg_dump` + `tar` folder `/kopdes` → cukup 2 file untuk pindah server.

**Out of scope Fase 0:** modul keanggotaan (data anggota/pengurus/karyawan/distributor), simpanan, simpan pinjam, POS, inventory, akuntansi, dashboard, multi-unit usaha (sembako/apotik/klinik/dll). Field `unitUsahaId` di `User` sudah ada sebagai persiapan, namun tabel `UnitUsaha` dan logic-nya dibangun di fase berikutnya.

---

## 2. Tech Stack & Infrastruktur

| Komponen | Pilihan | Catatan |
|---|---|---|
| Database | PostgreSQL 13.23 | Sudah tersedia di server. Pakai database `developerkdmpmy_premium_pg` + `_shadow` untuk Prisma migrate. |
| Backend | Node v24.15.0 + Express 4 + TypeScript + Prisma ORM | Mengikuti pattern proyek `layanan`. |
| Frontend | Next.js 15 (App Router) + React 19 + JavaScript (bukan TS) + Tailwind | Mengikuti pattern proyek `layanan`. |
| Auth | JWT (access 15 menit) + Refresh Token (httpOnly cookie, 7 hari, rotated) | bcryptjs untuk password hash (cost 12). |
| Validation | zod (backend + shared types) | |
| Security middleware | helmet, cors, express-rate-limit | CORS origin: `https://premium.developerkdmp.my.id` + `http://localhost:3100` untuk dev. |
| Queue & Idempotency | **PostgreSQL** (Redis tidak tersedia di cPanel) | Pattern: `SELECT ... FOR UPDATE SKIP LOCKED` + `pg_advisory_lock`. |
| Process manager | PM2 + `ecosystem.config.cjs` | App: `premium-backend`, `premium-frontend`, `premium-worker`. |
| Web server | Apache (cPanel default) + `.htaccess` proxy ke Node | Frontend di port 3100, backend di port 4100. |

---

## 3. Struktur Folder

```
/home/developerkdmpmy/
├── premium.developerkdmp.my.id/        # subdomain yang di-bind oleh cPanel
│   ├── .htaccess                       # proxy semua request ke Node frontend (port 3100)
│   ├── ecosystem.config.cjs            # PM2: backend, frontend, worker
│   ├── CLAUDE.md                       # dokumentasi proyek (untuk Claude/developer)
│   ├── README.md
│   ├── docs/superpowers/specs/         # spec & plan tiap fase
│   ├── backend/
│   │   ├── prisma/
│   │   │   ├── schema.prisma
│   │   │   ├── migrations/
│   │   │   └── seed.ts                 # super admin + role + permission default
│   │   ├── src/
│   │   │   ├── server.ts               # entry: bind Express, register router
│   │   │   ├── config/
│   │   │   │   ├── env.ts              # validasi env via zod
│   │   │   │   └── db.ts               # Prisma client singleton
│   │   │   ├── middleware/
│   │   │   │   ├── auth.ts             # JWT verify, attach user ke req
│   │   │   │   ├── rbac.ts             # requirePermission(module, action)
│   │   │   │   ├── idempotency.ts      # cek/insert IdempotencyKey
│   │   │   │   ├── audit.ts            # tulis AuditLog
│   │   │   │   └── error.ts            # error handler global
│   │   │   ├── lib/
│   │   │   │   ├── jwt.ts              # sign/verify
│   │   │   │   ├── hash.ts             # bcrypt wrapper
│   │   │   │   ├── lock.ts             # pg_advisory_lock helper
│   │   │   │   ├── queue.ts            # enqueue/dequeue Job
│   │   │   │   └── files.ts            # tulis ke /kopdes, dedup sha256
│   │   │   ├── modules/
│   │   │   │   ├── auth/               # login, logout, refresh, change-password, me
│   │   │   │   ├── user/               # CRUD user, reset-password, unlock
│   │   │   │   ├── role/               # CRUD role + permission matrix
│   │   │   │   ├── permission/         # list permission (untuk UI matrix)
│   │   │   │   ├── audit/              # query audit log
│   │   │   │   ├── files/              # upload, download (stream), delete
│   │   │   │   └── jobs/               # status job (untuk client polling)
│   │   │   └── workers/
│   │   │       ├── index.ts            # entry worker, consume jobs table
│   │   │       ├── jobs-runner.ts      # generic runner (dispatch by queue name)
│   │   │       └── housekeeping.ts     # bersihkan idempotency_keys & jobs expired
│   │   ├── .env.example
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── frontend/
│       ├── src/
│       │   ├── app/
│       │   │   ├── (public)/login/
│       │   │   ├── (auth)/dashboard/
│       │   │   ├── (auth)/admin/users/
│       │   │   ├── (auth)/admin/roles/
│       │   │   ├── (auth)/admin/audit-log/
│       │   │   ├── (auth)/profile/
│       │   │   └── layout.js
│       │   ├── components/
│       │   │   ├── shells/             # AppShell (sidebar + topbar) untuk halaman auth
│       │   │   ├── ui/                 # Button, Input, Table, Modal, Toast, ThemeToggle
│       │   │   └── forms/
│       │   ├── lib/
│       │   │   ├── api.js              # fetch wrapper + auto-refresh token
│       │   │   ├── auth-context.js     # React context user + permission
│       │   │   ├── use-permission.js   # hook { can(module, action) }
│       │   │   └── idempotency.js      # generate UUID v4 untuk header
│       │   └── styles/globals.css
│       ├── public/
│       ├── next.config.js
│       └── package.json
└── kopdes/                             # ✨ DI LUAR webroot, untuk migrasi mudah
    ├── anggota/                        # foto profil, scan KTP (fase berikutnya)
    ├── produk/                         # foto barang inventory (fase berikutnya)
    ├── artikel/                        # banner & gambar artikel (fase berikutnya)
    ├── dokumen/                        # surat, form, kontrak pinjaman
    ├── medis/                          # rekam medis apotik/klinik (fase berikutnya)
    └── tmp/                            # staging upload chunked
```

**Pattern migrasi:** untuk pindah server, cukup:
```bash
pg_dump developerkdmpmy_premium_pg | gzip > db.sql.gz
tar czf kopdes.tar.gz /home/developerkdmpmy/kopdes
# upload 2 file → restore DB + extract /kopdes → selesai
```

---

## 4. Model Data (Prisma Schema)

```prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider          = "postgresql"
  url               = env("DATABASE_URL")
  shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}

// === IDENTITAS & AKSES ===

model User {
  id              String    @id @default(cuid())
  email           String    @unique
  username        String    @unique
  passwordHash    String
  fullName        String
  phone           String?
  isActive        Boolean   @default(true)
  mustChangePass  Boolean   @default(false)
  lastLoginAt     DateTime?
  lastLoginIp     String?
  failedAttempts  Int       @default(0)
  lockedUntil     DateTime?
  roleId          String
  unitUsahaId     String?                    // null = akses semua unit; non-null disiapkan untuk fase berikutnya
  createdAt       DateTime  @default(now())
  updatedAt       DateTime  @updatedAt
  createdBy       String?
  role            Role      @relation(fields: [roleId], references: [id])

  @@map("users")
  @@index([roleId])
}

model Role {
  id           String           @id @default(cuid())
  name         String           @unique
  description  String?
  isSystem     Boolean          @default(false)  // SUPER_ADMIN tidak boleh dihapus/diubah permission-nya
  permissions  RolePermission[]
  users        User[]
  createdAt    DateTime         @default(now())
  updatedAt    DateTime         @updatedAt

  @@map("roles")
}

model Permission {
  id        String           @id @default(cuid())
  module    String           // "user", "role", "files", "audit"; fase berikutnya: "anggota", "pos", "inventory", dll
  action    String           // "read", "create", "update", "delete", "approve", "export"
  label     String           // "Lihat Daftar User" — untuk UI matrix
  roles     RolePermission[]

  @@unique([module, action])
  @@map("permissions")
}

model RolePermission {
  roleId       String
  permissionId String
  role         Role       @relation(fields: [roleId], references: [id], onDelete: Cascade)
  permission   Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)

  @@id([roleId, permissionId])
  @@map("role_permissions")
}

// === AUDIT (append-only) ===

model AuditLog {
  id          BigInt   @id @default(autoincrement())
  at          DateTime @default(now())
  userId      String?
  username    String?                    // snapshot, agar tetap terbaca jika user dihapus
  action      String                     // "LOGIN_OK", "LOGIN_FAIL", "USER_CREATE", "ROLE_UPDATE", dll
  module      String
  entityId    String?
  ip          String?
  userAgent   String?
  payload     Json?                      // before/after diff atau detail
  result      String                     // "OK" | "FAIL" | "DENIED"

  @@index([userId])
  @@index([module, at])
  @@index([entityId])
  @@map("audit_log")
}

// === TRANSACTION SAFETY ===

model IdempotencyKey {
  key         String   @id              // dari header "Idempotency-Key" (uuid v4)
  userId      String?
  endpoint    String                    // "POST /api/pos/transaksi"
  requestHash String                    // sha256 atas body request
  statusCode  Int
  response    Json
  createdAt   DateTime @default(now())
  expiresAt   DateTime                  // TTL 24 jam

  @@index([expiresAt])
  @@map("idempotency_keys")
}

model Job {
  id          BigInt   @id @default(autoincrement())
  queue       String                          // "transaksi_pos", "kirim_email", dll
  payload     Json
  status      String   @default("PENDING")    // PENDING | RUNNING | DONE | FAILED | DEAD
  attempts    Int      @default(0)
  maxAttempts Int      @default(5)
  runAt       DateTime @default(now())
  lockedAt    DateTime?
  lockedBy    String?                         // worker id (mis: pm2_instance_id)
  lastError   String?
  resultRef   String?
  createdAt   DateTime @default(now())
  finishedAt  DateTime?

  @@index([queue, status, runAt])
  @@map("jobs")
}

// === REFRESH TOKEN ===

model RefreshToken {
  id          String   @id @default(cuid())
  tokenHash   String   @unique           // sha256 dari raw token; raw token hanya di cookie client
  userId      String
  issuedAt    DateTime @default(now())
  expiresAt   DateTime
  revokedAt   DateTime?
  replacedBy  String?                    // id refresh token pengganti (untuk audit chain rotation)
  ip          String?
  userAgent   String?

  @@index([userId])
  @@index([expiresAt])
  @@map("refresh_tokens")
}

// === FILE STORAGE ===

model FileObject {
  id           String   @id @default(cuid())
  storagePath  String                          // relatif terhadap /home/.../kopdes, mis: "produk/2026/05/<cuid>.jpg"
  originalName String
  mimeType     String
  sizeBytes    BigInt
  sha256       String
  ownerModule  String                          // "anggota", "produk", "artikel", "dokumen", dll
  ownerId      String?
  isPublic     Boolean  @default(false)
  uploadedBy   String?
  uploadedAt   DateTime @default(now())
  deletedAt    DateTime?

  @@index([ownerModule, ownerId])
  @@index([sha256])
  @@map("file_objects")
}
```

**Keputusan design:**
- Audit log = **append-only**, ID `BigInt` untuk skala jutaan baris.
- `Role.isSystem=true` (mis: SUPER_ADMIN) tidak boleh dihapus atau diubah permission-nya lewat API; hanya seed yang boleh menulisnya.
- Setiap role non-system harus punya minimal 1 permission (validasi di service layer).
- `IdempotencyKey.requestHash` mencegah bug client: jika header `Idempotency-Key` sama dipakai dengan payload berbeda, server tolak 422.
- `User.unitUsahaId = null` berarti akses semua unit usaha (admin/ketua). Tabel `UnitUsaha` dibuat di fase berikutnya — di Fase 0 field ini selalu null kecuali diisi manual untuk testing.
- `FileObject.sha256` untuk deduplikasi: file dengan hash sama yang di-upload untuk owner sama tidak diduplikasi di disk.

---

## 5. Flow Sistem

### 5.1 Login
```
POST /api/auth/login  { usernameOrEmail, password }
  1. Cari user by username OR email
  2. Jika lockedUntil > now → 423 Locked
  3. bcrypt.compare(password, passwordHash)
  4. FAIL → failedAttempts++; jika >= 5 → lockedUntil = now + 15 menit;
     audit LOGIN_FAIL result=FAIL → 401
  5. OK → reset failedAttempts, set lastLoginAt/Ip
  6. Generate access token (JWT, 15 menit, payload: {sub, roleId})
     dan refresh token (random 64 byte, simpan hash di tabel RefreshToken, return raw via httpOnly cookie)
  7. audit LOGIN_OK result=OK
  ← 200 { user: {id, username, fullName, role: {id, name, permissions}}, accessToken, mustChangePass }
```

> Model `RefreshToken` lengkapnya didefinisikan di section 4 (sub-bagian "REFRESH TOKEN").

### 5.2 Refresh & Logout
```
POST /api/auth/refresh  (cookie refresh_token)
  → cari hash di tabel RefreshToken, cek expiresAt & revokedAt
  → rotate: revoke yang lama, generate yang baru
  ← { accessToken } + cookie baru

POST /api/auth/logout
  → revoke refresh token saat ini
  → audit LOGOUT
```

### 5.3 RBAC
```
Middleware requirePermission('user', 'create'):
  1. JWT verify → req.user = {id, roleId}
  2. Load Role + Permissions (cache in-memory TTL 60 detik per process; invalidate saat role/permission diupdate via event in-process)
  3. Jika roleName === 'SUPER_ADMIN' → ALLOW
  4. Else cek ada baris RolePermission untuk (roleId, permission.module=user, permission.action=create)
  5. DENIED → audit DENIED, 403
  6. OK → next()
```

### 5.4 Idempotency
```
Middleware idempotencyGuard (dipasang pada endpoint POST/PATCH/DELETE keuangan):
  1. Ambil header "Idempotency-Key" (UUID v4). Wajib untuk endpoint yang ditandai.
  2. requestHash = sha256(method + path + body)
  3. BEGIN
     SELECT FROM idempotency_keys WHERE key=$1 FOR UPDATE
       a) ADA & requestHash sama → return cached { statusCode, response }, skip handler
       b) ADA & requestHash beda → 422 { error: "IDEMPOTENCY_KEY_REUSED" }
       c) TIDAK ADA → handler dipanggil; setelah selesai, INSERT row dengan response & statusCode
     COMMIT
  4. expiresAt = now + 24 jam
```

### 5.5 Antrian Transaksi
```
Endpoint berat / harus-serial (dipakai di fase berikutnya, primitif disiapkan di Fase 0):
  1. Validasi + idempotency check
  2. enqueueJob({ queue: 'transaksi_pos', payload, runAt: now })
  3. return 202 Accepted { jobId, statusUrl: '/api/jobs/<id>' }

Worker (PM2 process `premium-worker`, 2 instance):
  loop {
    BEGIN
    SELECT FROM jobs WHERE status='PENDING' AND runAt <= now()
      ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1
    UPDATE status='RUNNING', lockedAt=now(), lockedBy=$workerId, attempts=attempts+1
    COMMIT
    try {
      handler[queue].run(payload)   // di transaksi DB dengan SERIALIZABLE jika perlu
      UPDATE status='DONE', finishedAt=now(), resultRef=...
    } catch (e) {
      if (attempts < maxAttempts) UPDATE status='PENDING', runAt=now + backoff(attempts), lastError=e.message
      else UPDATE status='DEAD', finishedAt=now(), lastError=e.message
    }
    sleep(100ms) jika tidak ada job
  }

Client polling:
  GET /api/jobs/:id → { status, attempts, lastError, resultRef }
```

### 5.6 Upload File
```
POST /api/files (multipart/form-data, max 10 MB)
  Body: file + { ownerModule, ownerId?, isPublic? }
  1. requirePermission('files', 'create')   // permission tunggal di Fase 0; fase berikutnya bisa tambah cek per ownerModule
  2. Hitung sha256 in-memory
  3. Cek FileObject existing by (sha256, ownerModule, ownerId) → kalau ada & belum deleted, return id-nya (dedup)
  4. Tulis ke /kopdes/<ownerModule>/<YYYY>/<MM>/<cuid>.<ext>
  5. INSERT FileObject
  ← 201 { id, downloadUrl: '/api/files/<id>' }

GET /api/files/:id
  1. Load FileObject; jika deleted → 404
  2. Jika !isPublic → requirePermission('files', 'read')
  3. Stream file dengan header:
       Content-Type, Content-Length, ETag (= sha256),
       Cache-Control: private, max-age=300
  4. Honor If-None-Match → 304

DELETE /api/files/:id
  1. requirePermission('files', 'delete')
  2. Soft delete (set deletedAt). File fisik dihapus oleh worker housekeeping
     setelah 30 hari (memberi grace period untuk recovery).
```

### 5.7 Housekeeping (Worker)
- Setiap 1 jam: hapus `idempotency_keys WHERE expiresAt < now()`.
- Setiap 1 jam: hapus `jobs WHERE status IN ('DONE','DEAD') AND finishedAt < now() - 7 hari`.
- Setiap 24 jam: hapus file fisik untuk `FileObject WHERE deletedAt < now() - 30 hari`, lalu hard-delete row.
- Setiap 24 jam: hapus `RefreshToken WHERE expiresAt < now() OR revokedAt < now() - 7 hari`.

---

## 6. Daftar Endpoint API Fase 0

Semua di-prefix `/api`. Format response error seragam: `{ error: <CODE>, message: <human readable>, details?: <zod issues> }`.

| Method | Path | Permission | Catatan |
|---|---|---|---|
| POST | /auth/login | public | rate-limit 10/menit/IP |
| POST | /auth/logout | authenticated | |
| POST | /auth/refresh | cookie | rate-limit 60/menit/IP |
| POST | /auth/change-password | authenticated | wajib jika `mustChangePass` |
| GET | /auth/me | authenticated | return user + role + permissions |
| GET | /users | user:read | paginated, search, filter role/active |
| POST | /users | user:create | |
| GET | /users/:id | user:read | |
| PATCH | /users/:id | user:update | tidak boleh ubah passwordHash di sini |
| DELETE | /users/:id | user:delete | soft (set isActive=false); tidak boleh hapus diri sendiri atau SUPER_ADMIN terakhir |
| POST | /users/:id/reset-password | user:update | generate random 16-char, return ke admin, set mustChangePass=true |
| POST | /users/:id/unlock | user:update | clear lockedUntil dan failedAttempts |
| GET | /roles | role:read | |
| POST | /roles | role:create | |
| GET | /roles/:id | role:read | include permissions |
| PATCH | /roles/:id | role:update | tolak jika `isSystem=true` |
| DELETE | /roles/:id | role:delete | tolak jika `isSystem=true` atau masih dipakai user |
| GET | /roles/:id/permissions | role:read | |
| PUT | /roles/:id/permissions | role:update | replace semua sekaligus, body: `{ permissionIds: [] }` |
| GET | /permissions | role:read | list seluruh permission tersedia (untuk UI matrix) |
| GET | /audit-log | audit:read | filter: module, userId, from, to, action, page, pageSize |
| POST | /files | files:create | multipart, max 10 MB |
| GET | /files/:id | files:read (atau public) | stream |
| DELETE | /files/:id | files:delete | soft delete |
| GET | /jobs/:id | jobs:read | |
| GET | /jobs | jobs:read | admin/debug; filter queue & status |
| GET | /health | public | { db: 'ok', queueDepth: n, diskFreeMB: n } |

---

## 7. Halaman Frontend Fase 0

| Path | Akses | Komponen utama |
|---|---|---|
| `/login` | public | LoginForm; redirect `/dashboard` jika sudah login |
| `/forgot-password` | public | Placeholder: "hubungi admin koperasi" (tanpa SMTP di Fase 0) |
| `/dashboard` | authenticated | Halaman placeholder (widget diisi Fase 5) |
| `/admin/users` | user:read | Table + search/filter + tombol Tambah |
| `/admin/users/new` | user:create | Form: username, email, fullName, phone, role, unitUsahaId (disabled di Fase 0) |
| `/admin/users/:id` | user:read/update | Form edit + tombol Reset Password & Unlock |
| `/admin/roles` | role:read | Table + tombol Tambah |
| `/admin/roles/:id` | role:read/update | Matrix permission: row=module, col=action, checkbox |
| `/admin/audit-log` | audit:read | Table dengan filter |
| `/profile` | authenticated | Edit profil sendiri + form ganti password |

Layout: `AppShell` dengan sidebar dinamis (item disembunyikan jika tidak punya permission via hook `usePermission`) + topbar (user dropdown, theme toggle, logout).

---

## 8. Seed Awal

`prisma/seed.ts` melakukan:

1. **Seed Permission** — list permission Fase 0:
   ```
   user:read, user:create, user:update, user:delete
   role:read, role:create, role:update, role:delete
   files:read, files:create, files:delete
   audit:read
   jobs:read
   ```
   Idempotent: `upsert` by (module, action).

2. **Seed Role SUPER_ADMIN** — isSystem=true, link ke semua permission.

3. **Seed User super admin pertama** (hanya jika belum ada user sama sekali):
   - username: `superadmin`
   - email: `superadmin@kopdes.local`
   - password: random 16 char, dicetak ke stdout sekali
   - mustChangePass: true
   - roleId: SUPER_ADMIN

4. **Cetak warning** ke stdout untuk segera ganti password & email setelah login pertama.

---

## 9. Konfigurasi Deployment cPanel

### 9.1 `ecosystem.config.cjs`
```js
module.exports = {
  apps: [
    { name: 'premium-backend',  cwd: './backend',  script: 'dist/server.js', instances: 1, env: { NODE_ENV: 'production', PORT: 4100 } },
    { name: 'premium-frontend', cwd: './frontend', script: 'node_modules/next/dist/bin/next', args: 'start -p 3100', env: { NODE_ENV: 'production' } },
    { name: 'premium-worker',   cwd: './backend',  script: 'dist/workers/index.js', instances: 2, env: { NODE_ENV: 'production', WORKER: '1' } }
  ]
};
```

### 9.2 `.htaccess` (di root subdomain)
```apache
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/api/
RewriteRule ^api/(.*)$ http://127.0.0.1:4100/api/$1 [P,L]
RewriteRule ^(.*)$ http://127.0.0.1:3100/$1 [P,L]
```

### 9.3 `.env` (backend)
```
NODE_ENV=production
PORT=4100
CORS_ORIGIN=https://premium.developerkdmp.my.id,http://localhost:3100
DATABASE_URL="postgresql://developerkdmpmy_premium:<PASS>@127.0.0.1:5432/developerkdmpmy_premium_pg"
SHADOW_DATABASE_URL="postgresql://developerkdmpmy_premium:<PASS>@127.0.0.1:5432/developerkdmpmy_premium_shadow"
JWT_SECRET=<random 64 char>
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
KOPDES_DIR=/home/developerkdmpmy/kopdes
BCRYPT_COST=12
RATE_LIMIT_LOGIN_PER_MIN=10
```

---

## 10. Testing Strategy

| Lapis | Tools | Target Coverage |
|---|---|---|
| Unit (backend) | vitest | lib/jwt, lib/hash, lib/lock, lib/queue, lib/files, service layer modul auth/user/role |
| Integration (backend) | vitest + supertest + Prisma test DB | semua endpoint /api/* (happy path + error path + RBAC denied + idempotency replay) |
| E2E ringan (frontend) | Playwright (smoke saja di Fase 0) | login → buat user → assign role → logout |

**Wajib lulus sebelum mark Fase 0 selesai:**
- Login + RBAC + audit log + idempotency replay + queue smoke test (enqueue job → worker consume → DONE).
- `npm run typecheck && npm run lint && npm run test` lulus di backend & frontend.

---

## 11. Risiko & Mitigasi

| Risiko | Mitigasi |
|---|---|
| Worker stuck karena `lockedBy` tidak di-release (crash) | Job dengan `status='RUNNING' AND lockedAt < now()-5min` di-reset ke PENDING oleh housekeeping |
| Throughput PostgreSQL-as-queue terbatas | Cukup untuk 5-10rb anggota; jika nanti perlu, tambah index parsial `WHERE status='PENDING'` |
| Folder /kopdes tumbuh besar | Quota disk cPanel dipantau lewat `/api/health`; soft-delete + grace 30 hari sebelum hard-delete |
| Password super admin tercetak hanya di stdout | Operator harus catat saat seed jalan. Jika hilang, jalankan script `npm run admin:reset-password -- --username=superadmin` (script terpisah di `backend/src/scripts/reset-admin-password.ts` yang generate password baru, set `mustChangePass=true`, cetak ke stdout). |
| Refresh token bocor | Rotated tiap refresh; revoked saat logout; expires 7 hari; cookie httpOnly + Secure + SameSite=Lax |

---

## 12. Definition of Done Fase 0

- [ ] Skeleton folder lengkap, ter-commit di git (`/home/developerkdmpmy/premium.developerkdmp.my.id/`).
- [ ] `prisma migrate deploy` jalan tanpa error di DB `developerkdmpmy_premium_pg`.
- [ ] `prisma db seed` menghasilkan SUPER_ADMIN + permission list + cetak password.
- [ ] PM2 menjalankan 3 process (backend, frontend, worker) tanpa error.
- [ ] Akses `https://premium.developerkdmp.my.id/login` → bisa login → diarahkan ke `/dashboard`.
- [ ] Admin bisa create user, create role, set permission matrix, lihat audit log.
- [ ] Upload file test (PNG <1MB) sukses; download via /api/files/:id berhasil; DELETE soft-delete OK.
- [ ] Test integration semua endpoint lulus; idempotency replay test (kirim 2× POST dengan key sama → response identik) lulus.
- [ ] Smoke worker: enqueue dummy job → worker consume → status DONE dalam <2 detik.
- [ ] `pg_dump` + `tar /kopdes` → restore di environment kosong → aplikasi jalan normal.

---

## 13. Out of Scope (eksplisit, untuk fase berikutnya)

- **Fase 1:** Data Anggota, Pengurus, Karyawan, Distributor (+ tabel `UnitUsaha` resmi).
- **Fase 2:** Simpanan (Pokok/Wajib/Sukarela), Dana Cadangan, SHU; Simpan Pinjam.
- **Fase 3:** Inventory & Pergudangan (multi-gudang); Pricing Engine (anggota vs non-anggota); POS/Kasir; Sistem Poin.
- **Fase 4:** Akuntansi (jurnal, buku besar, neraca, R/L, SHU otomatis); Modul Apotik (resep, BPOM); Klinik (rekam medis, ICD-10); Cold Storage.
- **Fase 5:** Dashboard widget; Laporan + grafik; Export PDF/Excel.

Setiap fase = spec terpisah + plan terpisah + cycle implementasi+testing terpisah.
