# Koperasi Fase 0 Foundation — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Bangun skeleton + fondasi Koperasi Desa Merah Putih: identitas/akses, audit log, transaction-safety primitives, file storage — siap deploy ke `premium.developerkdmp.my.id` dan dipakai sebagai dasar modul bisnis fase berikutnya.

**Architecture:** Backend Express+Prisma+PostgreSQL di port 4100, frontend Next.js di port 3100, worker PM2 untuk antrian job. Semua file user disimpan di `/home/developerkdmpmy/kopdes/` (di luar webroot) dengan akses via signed endpoint backend. Idempotency & queue pakai PostgreSQL (`FOR UPDATE SKIP LOCKED` + `pg_advisory_lock`), tanpa Redis.

**Tech Stack:** Node 24, TypeScript (backend), JavaScript (frontend), Next.js 15, React 19, Express 4, Prisma 5, PostgreSQL 13, zod, bcryptjs, jose (JWT), multer, vitest, supertest, Playwright (smoke), PM2.

**Spec:** [`../specs/2026-05-26-koperasi-fase0-foundation-design.md`](../specs/2026-05-26-koperasi-fase0-foundation-design.md)

**Project root:** `/home/developerkdmpmy/premium.developerkdmp.my.id/` — selanjutnya disebut `$ROOT`. Semua perintah `cd $ROOT/backend` atau `$ROOT/frontend` kecuali dinyatakan lain.

**Konvensi commit:** Conventional Commits — `feat:`, `fix:`, `chore:`, `test:`, `docs:`, `refactor:`.

---

## Task 1: Repository Scaffolding

**Files:**
- Create: `$ROOT/.gitignore`
- Create: `$ROOT/README.md`
- Create: `$ROOT/CLAUDE.md`
- Create: `$ROOT/.editorconfig`

- [ ] **Step 1.1:** Init git repo

```bash
cd /home/developerkdmpmy/premium.developerkdmp.my.id
git init -b main
git config user.email "developer@developerkdmp.my.id"
git config user.name "DeveloperKDMP"
```

- [ ] **Step 1.2:** Create `.gitignore`

```gitignore
node_modules/
dist/
.next/
out/
build/
coverage/
*.log
.DS_Store
.env
.env.local
.env.*.local
!.env.example
*.tsbuildinfo
.vscode/
.idea/
```

- [ ] **Step 1.3:** Create `README.md`

```markdown
# Koperasi Desa Merah Putih — `premium.developerkdmp.my.id`

Aplikasi koperasi desa: keanggotaan, simpanan, simpan pinjam, POS multi-unit, akuntansi.

## Stack
- Frontend: Next.js 15 (App Router) + React 19 + JavaScript + Tailwind — port **3100**
- Backend: Express 4 + TypeScript + Prisma + PostgreSQL 13 — port **4100**
- Process: PM2 (`premium-backend`, `premium-frontend`, `premium-worker`)
- File storage: `/home/developerkdmpmy/kopdes/` (di luar webroot)

## Quick Start
```bash
# backend
cd backend && npm install && cp .env.example .env  # isi DATABASE_URL & JWT_SECRET
npx prisma migrate deploy && npx prisma db seed     # cetak password super admin
npm run dev

# frontend
cd frontend && npm install && npm run dev
```

## Roadmap
- Fase 0: Foundation (identitas, akses, audit, queue, file) — **dokumen ini**
- Fase 1: Keanggotaan
- Fase 2: Simpanan + Simpan Pinjam
- Fase 3: Inventory + POS + Pricing + Poin
- Fase 4: Akuntansi + Apotik + Klinik + Cold Storage
- Fase 5: Dashboard + Laporan

Detail: [docs/superpowers/specs/](docs/superpowers/specs/) dan [docs/superpowers/plans/](docs/superpowers/plans/).
```

- [ ] **Step 1.4:** Create `CLAUDE.md`

```markdown
# premium — Koperasi Desa Merah Putih

Subdomain: **premium.developerkdmp.my.id**.

## Stack
- Frontend: Next.js 15 + React 19 + JavaScript + Tailwind — port **3100**
- Backend: Express 4 + TypeScript + Prisma + PostgreSQL — port **4100**
- Worker: PM2 process terpisah, consume tabel `jobs` (FOR UPDATE SKIP LOCKED)
- Auth: JWT access 15m + refresh 7d (httpOnly cookie, rotated)
- File: `/home/developerkdmpmy/kopdes/` (di luar webroot, stream via backend)
- Validation: zod

## Commands
```bash
# backend
cd backend
npm run build && npx prisma migrate deploy && pm2 restart premium-backend

# frontend  
cd frontend && npm run build && pm2 restart premium-frontend

# worker
pm2 restart premium-worker
```

## DB
- Database utama: `developerkdmpmy_premium_pg`
- Shadow: `developerkdmpmy_premium_shadow`
- Buat lewat cPanel → User & Database Postgres.

## Penting
- JANGAN edit folder `/kopdes` langsung di disk — semua tulis lewat `lib/files.ts`.
- JANGAN UPDATE/DELETE row `audit_log` — append only.
- Endpoint keuangan WAJIB pakai header `Idempotency-Key` (UUID v4 dari client).
```

- [ ] **Step 1.5:** Create `.editorconfig`

```
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
```

- [ ] **Step 1.6:** Commit

```bash
git add .gitignore README.md CLAUDE.md .editorconfig docs/
git commit -m "chore: scaffold repo with README, CLAUDE.md, gitignore"
```

---

## Task 2: Backend Skeleton

**Files:**
- Create: `$ROOT/backend/package.json`
- Create: `$ROOT/backend/tsconfig.json`
- Create: `$ROOT/backend/vitest.config.ts`
- Create: `$ROOT/backend/.env.example`
- Create: `$ROOT/backend/src/config/env.ts`
- Create: `$ROOT/backend/src/config/db.ts`
- Create: `$ROOT/backend/src/server.ts`
- Create: `$ROOT/backend/src/modules/health/health.controller.ts`
- Create: `$ROOT/backend/src/__tests__/health.test.ts`

- [ ] **Step 2.1:** Create `backend/package.json`

```json
{
  "name": "premium-backend",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "worker": "node dist/workers/index.js",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:watch": "vitest",
    "prisma": "prisma",
    "admin:reset-password": "tsx src/scripts/reset-admin-password.ts"
  },
  "prisma": { "seed": "tsx prisma/seed.ts" },
  "dependencies": {
    "@prisma/client": "^5.22.0",
    "bcryptjs": "^2.4.3",
    "cookie-parser": "^1.4.7",
    "cors": "^2.8.5",
    "dotenv": "^16.4.5",
    "express": "^4.21.0",
    "express-rate-limit": "^7.4.0",
    "helmet": "^7.2.0",
    "jose": "^5.9.0",
    "multer": "^1.4.5-lts.1",
    "pino": "^9.4.0",
    "pino-http": "^10.3.0",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "@types/bcryptjs": "^2.4.6",
    "@types/cookie-parser": "^1.4.7",
    "@types/cors": "^2.8.17",
    "@types/express": "^4.17.21",
    "@types/multer": "^1.4.12",
    "@types/node": "^22.7.0",
    "@types/supertest": "^6.0.2",
    "prisma": "^5.22.0",
    "supertest": "^7.0.0",
    "tsx": "^4.19.0",
    "typescript": "^5.6.0",
    "vitest": "^2.1.0"
  }
}
```

- [ ] **Step 2.2:** Create `backend/tsconfig.json`

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "outDir": "./dist",
    "rootDir": "./src",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "declaration": false,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}
```

- [ ] **Step 2.3:** Create `backend/vitest.config.ts`

```typescript
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    setupFiles: [],
    testTimeout: 10000,
    pool: 'forks',
    poolOptions: { forks: { singleFork: true } },
  },
});
```

- [ ] **Step 2.4:** Create `backend/.env.example`

```
NODE_ENV=development
PORT=4100
CORS_ORIGIN=http://localhost:3100
DATABASE_URL="postgresql://USER:PASS@127.0.0.1:5432/developerkdmpmy_premium_pg"
SHADOW_DATABASE_URL="postgresql://USER:PASS@127.0.0.1:5432/developerkdmpmy_premium_shadow"
JWT_SECRET="ganti-dengan-64-char-random"
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
KOPDES_DIR=/home/developerkdmpmy/kopdes
BCRYPT_COST=12
RATE_LIMIT_LOGIN_PER_MIN=10
COOKIE_SECURE=false
```

- [ ] **Step 2.5:** Install deps

```bash
cd $ROOT/backend && npm install
```
Expected: 0 vulnerabilities critical; node_modules ada.

- [ ] **Step 2.6:** Create `src/config/env.ts`

```typescript
import 'dotenv/config';
import { z } from 'zod';

const Schema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().default(4100),
  CORS_ORIGIN: z.string(),
  DATABASE_URL: z.string().url(),
  SHADOW_DATABASE_URL: z.string().url().optional(),
  JWT_SECRET: z.string().min(32),
  JWT_ACCESS_EXPIRES: z.string().default('15m'),
  JWT_REFRESH_EXPIRES: z.string().default('7d'),
  KOPDES_DIR: z.string(),
  BCRYPT_COST: z.coerce.number().min(10).max(14).default(12),
  RATE_LIMIT_LOGIN_PER_MIN: z.coerce.number().default(10),
  COOKIE_SECURE: z.coerce.boolean().default(false),
});

export const env = Schema.parse(process.env);
export type Env = typeof env;
```

- [ ] **Step 2.7:** Create `src/config/db.ts`

```typescript
import { PrismaClient } from '@prisma/client';
import { env } from './env.js';

export const prisma = new PrismaClient({
  log: env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});

export async function disconnectDb(): Promise<void> {
  await prisma.$disconnect();
}
```

- [ ] **Step 2.8:** Write failing test `src/__tests__/health.test.ts`

```typescript
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { createApp } from '../server.js';

describe('GET /api/health', () => {
  it('returns 200 with db ok', async () => {
    const app = await createApp();
    const res = await request(app).get('/api/health');
    expect(res.status).toBe(200);
    expect(res.body.db).toBe('ok');
  });
});
```

- [ ] **Step 2.9:** Run test, verify FAIL

```bash
cd $ROOT/backend && npm test
```
Expected: FAIL — module `../server.js` not found.

- [ ] **Step 2.10:** Create `src/modules/health/health.controller.ts`

```typescript
import { Router, type Request, type Response } from 'express';
import { prisma } from '../../config/db.js';

export const healthRouter = Router();

healthRouter.get('/', async (_req: Request, res: Response) => {
  let db: 'ok' | 'fail' = 'ok';
  try {
    await prisma.$queryRaw`SELECT 1`;
  } catch {
    db = 'fail';
  }
  res.json({ db, uptime: process.uptime(), now: new Date().toISOString() });
});
```

- [ ] **Step 2.11:** Create `src/server.ts`

```typescript
import express, { type Express } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import { env } from './config/env.js';
import { healthRouter } from './modules/health/health.controller.js';

export async function createApp(): Promise<Express> {
  const app = express();
  app.disable('x-powered-by');
  app.use(helmet());
  app.use(cors({ origin: env.CORS_ORIGIN.split(','), credentials: true }));
  app.use(express.json({ limit: '1mb' }));
  app.use(cookieParser());

  app.use('/api/health', healthRouter);

  app.use((_req, res) => res.status(404).json({ error: 'NOT_FOUND' }));
  return app;
}

async function main(): Promise<void> {
  const app = await createApp();
  app.listen(env.PORT, () => {
    console.log(`[premium-backend] listening on :${env.PORT}`);
  });
}

const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
  main().catch((e) => {
    console.error(e);
    process.exit(1);
  });
}
```

- [ ] **Step 2.12:** Run test, verify it still fails due to missing DATABASE_URL

```bash
cp .env.example .env
# edit .env: set DATABASE_URL valid; JWT_SECRET min 32 char (sementara: openssl rand -hex 32)
npm test
```
Expected: PASS jika DB connectable; FAIL `db: 'fail'` jika DB belum dibuat — itu OK untuk sementara, kita akan setup DB di Task 4.

Untuk test sementara, ubah expectasi: terima `db` dalam `['ok','fail']`. Edit test:

```typescript
expect(['ok','fail']).toContain(res.body.db);
```

Jalankan lagi: `npm test` → PASS.

- [ ] **Step 2.13:** Commit

```bash
git add backend/package.json backend/package-lock.json backend/tsconfig.json backend/vitest.config.ts backend/.env.example backend/src
git commit -m "feat(backend): skeleton express server with health endpoint and env validation"
```

---

## Task 3: Frontend Skeleton

**Files:**
- Create: `$ROOT/frontend/package.json`
- Create: `$ROOT/frontend/next.config.js`
- Create: `$ROOT/frontend/tailwind.config.js`
- Create: `$ROOT/frontend/postcss.config.js`
- Create: `$ROOT/frontend/jsconfig.json`
- Create: `$ROOT/frontend/src/app/layout.js`
- Create: `$ROOT/frontend/src/app/page.js`
- Create: `$ROOT/frontend/src/app/globals.css`
- Create: `$ROOT/frontend/.env.local.example`

- [ ] **Step 3.1:** Create `frontend/package.json`

```json
{
  "name": "premium-frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3100",
    "build": "next build",
    "start": "next start -p 3100",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.7.0",
    "autoprefixer": "^10.4.20",
    "eslint": "^9.12.0",
    "eslint-config-next": "^15.0.0",
    "postcss": "^8.4.47",
    "tailwindcss": "^3.4.13"
  }
}
```

- [ ] **Step 3.2:** Install

```bash
cd $ROOT/frontend && npm install
```

- [ ] **Step 3.3:** Create `frontend/next.config.js`

```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return [{ source: '/api/:path*', destination: 'http://127.0.0.1:4100/api/:path*' }];
  },
};
export default nextConfig;
```

- [ ] **Step 3.4:** Create `frontend/tailwind.config.js`

```javascript
/** @type {import('tailwindcss').Config} */
export default {
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  theme: { extend: {} },
  plugins: [],
};
```

- [ ] **Step 3.5:** Create `frontend/postcss.config.js`

```javascript
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
```

- [ ] **Step 3.6:** Create `frontend/jsconfig.json`

```json
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
```

- [ ] **Step 3.7:** Create `frontend/src/app/globals.css`

```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

- [ ] **Step 3.8:** Create `frontend/src/app/layout.js`

```javascript
import './globals.css';

export const metadata = {
  title: 'Koperasi Desa Merah Putih',
  description: 'Sistem informasi koperasi desa',
};

export default function RootLayout({ children }) {
  return (
    <html lang="id">
      <body className="min-h-screen bg-gray-50 text-gray-900 antialiased">{children}</body>
    </html>
  );
}
```

- [ ] **Step 3.9:** Create `frontend/src/app/page.js`

```javascript
export default function Home() {
  return (
    <main className="flex min-h-screen items-center justify-center">
      <div className="text-center">
        <h1 className="text-3xl font-bold">Koperasi Desa Merah Putih</h1>
        <p className="mt-2 text-gray-600">Aplikasi sedang dibangun. Silakan login untuk akses.</p>
        <a href="/login" className="mt-4 inline-block rounded bg-blue-600 px-4 py-2 text-white">
          Masuk
        </a>
      </div>
    </main>
  );
}
```

- [ ] **Step 3.10:** Create `frontend/.env.local.example`

```
# kosong; rewrites menggunakan 127.0.0.1:4100 langsung
```

- [ ] **Step 3.11:** Build & verify

```bash
cd $ROOT/frontend && npm run build
```
Expected: build success, route `/` muncul.

- [ ] **Step 3.12:** Commit

```bash
git add frontend/
git commit -m "feat(frontend): scaffold Next.js 15 with Tailwind and proxy rewrite to backend"
```

---

## Task 4: Prisma Schema & Initial Migration

**Files:**
- Create: `$ROOT/backend/prisma/schema.prisma`

- [ ] **Step 4.1:** Init Prisma

```bash
cd $ROOT/backend && npx prisma init --datasource-provider postgresql
```
Expected: `prisma/schema.prisma` dan baris `DATABASE_URL` di `.env` ditambahkan (timpa kalau sudah ada).

- [ ] **Step 4.2:** Tulis schema lengkap — replace seluruh `prisma/schema.prisma` dengan:

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

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

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?
  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)
  permissions  RolePermission[]
  users        User[]
  createdAt    DateTime         @default(now())
  updatedAt    DateTime         @updatedAt

  @@map("roles")
}

model Permission {
  id     String           @id @default(cuid())
  module String
  action String
  label  String
  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")
}

model AuditLog {
  id        BigInt   @id @default(autoincrement())
  at        DateTime @default(now())
  userId    String?
  username  String?
  action    String
  module    String
  entityId  String?
  ip        String?
  userAgent String?
  payload   Json?
  result    String

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

model IdempotencyKey {
  key         String   @id
  userId      String?
  endpoint    String
  requestHash String
  statusCode  Int
  response    Json
  createdAt   DateTime @default(now())
  expiresAt   DateTime

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

model Job {
  id          BigInt    @id @default(autoincrement())
  queue       String
  payload     Json
  status      String    @default("PENDING")
  attempts    Int       @default(0)
  maxAttempts Int       @default(5)
  runAt       DateTime  @default(now())
  lockedAt    DateTime?
  lockedBy    String?
  lastError   String?
  resultRef   String?
  createdAt   DateTime  @default(now())
  finishedAt  DateTime?

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

model RefreshToken {
  id         String    @id @default(cuid())
  tokenHash  String    @unique
  userId     String
  issuedAt   DateTime  @default(now())
  expiresAt  DateTime
  revokedAt  DateTime?
  replacedBy String?
  ip         String?
  userAgent  String?

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

model FileObject {
  id           String    @id @default(cuid())
  storagePath  String
  originalName String
  mimeType     String
  sizeBytes    BigInt
  sha256       String
  ownerModule  String
  ownerId      String?
  isPublic     Boolean   @default(false)
  uploadedBy   String?
  uploadedAt   DateTime  @default(now())
  deletedAt    DateTime?

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

- [ ] **Step 4.3:** Buat database via cPanel (manual)

Login cPanel → PostgreSQL Databases → create:
- DB: `developerkdmpmy_premium_pg`
- DB: `developerkdmpmy_premium_shadow`
- User: `developerkdmpmy_premium` dengan password kuat (catat untuk `.env`)
- Assign user ke kedua DB dengan privilege ALL

Update `$ROOT/backend/.env`:
```
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"
```

- [ ] **Step 4.4:** Generate migration

```bash
cd $ROOT/backend && npx prisma migrate dev --name init_fase0
```
Expected: migration created di `prisma/migrations/<ts>_init_fase0/migration.sql`; Prisma Client di-generate.

- [ ] **Step 4.5:** Verifikasi koneksi

```bash
npm test
```
Expected: PASS dengan `db: 'ok'`.

Update test ke ekspektasi strict:
```typescript
expect(res.body.db).toBe('ok');
```

- [ ] **Step 4.6:** Commit

```bash
git add backend/prisma/schema.prisma backend/prisma/migrations backend/src/__tests__/health.test.ts
git commit -m "feat(db): add Prisma schema for Fase 0 models and initial migration"
```

---

## Task 5: Seed Permission, SUPER_ADMIN Role, Super Admin User

**Files:**
- Create: `$ROOT/backend/prisma/seed.ts`
- Create: `$ROOT/backend/src/lib/permissions-catalog.ts`

- [ ] **Step 5.1:** Create `src/lib/permissions-catalog.ts`

```typescript
export interface PermissionDef {
  module: string;
  action: string;
  label: string;
}

export const PERMISSIONS_CATALOG: readonly PermissionDef[] = [
  { module: 'user',  action: 'read',   label: 'Lihat User' },
  { module: 'user',  action: 'create', label: 'Buat User' },
  { module: 'user',  action: 'update', label: 'Ubah User' },
  { module: 'user',  action: 'delete', label: 'Hapus/Nonaktifkan User' },
  { module: 'role',  action: 'read',   label: 'Lihat Role' },
  { module: 'role',  action: 'create', label: 'Buat Role' },
  { module: 'role',  action: 'update', label: 'Ubah Role / Set Permission' },
  { module: 'role',  action: 'delete', label: 'Hapus Role' },
  { module: 'files', action: 'read',   label: 'Unduh File' },
  { module: 'files', action: 'create', label: 'Unggah File' },
  { module: 'files', action: 'delete', label: 'Hapus File' },
  { module: 'audit', action: 'read',   label: 'Lihat Audit Log' },
  { module: 'jobs',  action: 'read',   label: 'Lihat Status Job/Antrian' },
] as const;
```

- [ ] **Step 5.2:** Create `prisma/seed.ts`

```typescript
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { PERMISSIONS_CATALOG } from '../src/lib/permissions-catalog.js';

const prisma = new PrismaClient();
const SUPER_ADMIN = 'SUPER_ADMIN';

function generatePassword(): string {
  // 16 char dari 64-set alfanumerik + simbol aman
  const charset = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#%*';
  const bytes = randomBytes(16);
  return Array.from(bytes, (b) => charset[b % charset.length]).join('');
}

async function main(): Promise<void> {
  // 1. Permission upsert
  for (const p of PERMISSIONS_CATALOG) {
    await prisma.permission.upsert({
      where: { module_action: { module: p.module, action: p.action } },
      update: { label: p.label },
      create: p,
    });
  }
  console.log(`[seed] permissions: ${PERMISSIONS_CATALOG.length}`);

  // 2. Role SUPER_ADMIN
  const allPerms = await prisma.permission.findMany();
  const role = await prisma.role.upsert({
    where: { name: SUPER_ADMIN },
    update: { isSystem: true },
    create: { name: SUPER_ADMIN, description: 'Akses penuh sistem', isSystem: true },
  });
  // Reset permission set untuk SUPER_ADMIN (selalu semua)
  await prisma.rolePermission.deleteMany({ where: { roleId: role.id } });
  await prisma.rolePermission.createMany({
    data: allPerms.map((p) => ({ roleId: role.id, permissionId: p.id })),
  });
  console.log(`[seed] role SUPER_ADMIN with ${allPerms.length} permissions`);

  // 3. Super admin user — hanya buat kalau belum ada user sama sekali
  const count = await prisma.user.count();
  if (count === 0) {
    const password = generatePassword();
    const passwordHash = await bcrypt.hash(password, 12);
    await prisma.user.create({
      data: {
        username: 'superadmin',
        email: 'superadmin@kopdes.local',
        passwordHash,
        fullName: 'Super Admin',
        roleId: role.id,
        mustChangePass: true,
      },
    });
    console.log('');
    console.log('================================================================');
    console.log('  SUPER ADMIN BARU DIBUAT — CATAT KREDENSIAL INI SEKARANG');
    console.log('  username : superadmin');
    console.log('  email    : superadmin@kopdes.local');
    console.log(`  password : ${password}`);
    console.log('  → Login pertama akan dipaksa ganti password.');
    console.log('================================================================');
    console.log('');
  } else {
    console.log(`[seed] user count=${count}, skip super admin creation`);
  }
}

main()
  .catch((e) => {
    console.error('[seed] FAILED', e);
    process.exit(1);
  })
  .finally(() => prisma.$disconnect());
```

- [ ] **Step 5.3:** Run seed

```bash
cd $ROOT/backend && npx prisma db seed
```
Expected: cetak permission count + cetak kredensial super admin (catat password!).

- [ ] **Step 5.4:** Verifikasi via psql

```bash
psql "$DATABASE_URL" -c "SELECT username, email, mustChangePass FROM users; SELECT name, isSystem FROM roles; SELECT COUNT(*) FROM permissions;"
```
Expected: 1 user superadmin (mustChangePass=true), 1 role SUPER_ADMIN, 13 permissions.

- [ ] **Step 5.5:** Commit

```bash
git add backend/prisma/seed.ts backend/src/lib/permissions-catalog.ts
git commit -m "feat(seed): bootstrap permissions catalog, SUPER_ADMIN role, super admin user"
```

---

## Task 6: lib/hash (bcrypt wrapper)

**Files:**
- Create: `$ROOT/backend/src/lib/hash.ts`
- Create: `$ROOT/backend/src/__tests__/lib/hash.test.ts`

- [ ] **Step 6.1:** Write failing test

```typescript
import { describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword } from '../../lib/hash.js';

describe('hash lib', () => {
  it('hashes and verifies password correctly', async () => {
    const hash = await hashPassword('rahasia123');
    expect(hash).not.toBe('rahasia123');
    expect(await verifyPassword('rahasia123', hash)).toBe(true);
    expect(await verifyPassword('salah', hash)).toBe(false);
  });
});
```

- [ ] **Step 6.2:** Run test → FAIL (module not found)

- [ ] **Step 6.3:** Implement `src/lib/hash.ts`

```typescript
import bcrypt from 'bcryptjs';
import { env } from '../config/env.js';

export async function hashPassword(plain: string): Promise<string> {
  return bcrypt.hash(plain, env.BCRYPT_COST);
}

export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
  return bcrypt.compare(plain, hash);
}
```

- [ ] **Step 6.4:** Run test → PASS

- [ ] **Step 6.5:** Commit

```bash
git add backend/src/lib/hash.ts backend/src/__tests__/lib/hash.test.ts
git commit -m "feat(lib): hashPassword/verifyPassword wrapper around bcryptjs"
```

---

## Task 7: lib/jwt (access & refresh tokens)

**Files:**
- Create: `$ROOT/backend/src/lib/jwt.ts`
- Create: `$ROOT/backend/src/__tests__/lib/jwt.test.ts`

- [ ] **Step 7.1:** Write failing test

```typescript
import { describe, it, expect } from 'vitest';
import { signAccessToken, verifyAccessToken } from '../../lib/jwt.js';

describe('jwt lib', () => {
  it('signs and verifies access token', async () => {
    const token = await signAccessToken({ sub: 'user-1', roleId: 'role-1' });
    const payload = await verifyAccessToken(token);
    expect(payload.sub).toBe('user-1');
    expect(payload.roleId).toBe('role-1');
  });

  it('rejects tampered token', async () => {
    const token = await signAccessToken({ sub: 'user-1', roleId: 'role-1' });
    const tampered = token.slice(0, -2) + 'XX';
    await expect(verifyAccessToken(tampered)).rejects.toThrow();
  });
});
```

- [ ] **Step 7.2:** Run → FAIL

- [ ] **Step 7.3:** Implement `src/lib/jwt.ts`

```typescript
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
import { env } from '../config/env.js';

const SECRET = new TextEncoder().encode(env.JWT_SECRET);
const ISSUER = 'premium-backend';
const AUDIENCE = 'premium-frontend';

export interface AccessTokenPayload extends JWTPayload {
  sub: string;
  roleId: string;
}

export async function signAccessToken(payload: AccessTokenPayload): Promise<string> {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setExpirationTime(env.JWT_ACCESS_EXPIRES)
    .sign(SECRET);
}

export async function verifyAccessToken(token: string): Promise<AccessTokenPayload> {
  const { payload } = await jwtVerify(token, SECRET, { issuer: ISSUER, audience: AUDIENCE });
  if (typeof payload.sub !== 'string' || typeof payload.roleId !== 'string') {
    throw new Error('Invalid token payload');
  }
  return payload as AccessTokenPayload;
}

// Refresh token: raw random 64 bytes hex (128 char). Hash-nya yang disimpan di DB.
import { randomBytes, createHash } from 'crypto';

export function generateRefreshTokenRaw(): string {
  return randomBytes(64).toString('hex');
}

export function hashRefreshToken(raw: string): string {
  return createHash('sha256').update(raw).digest('hex');
}
```

- [ ] **Step 7.4:** Run → PASS

- [ ] **Step 7.5:** Commit

```bash
git add backend/src/lib/jwt.ts backend/src/__tests__/lib/jwt.test.ts
git commit -m "feat(lib): jwt access token sign/verify + refresh token raw/hash helpers"
```

---

## Task 8: lib/lock (pg_advisory_lock helpers)

**Files:**
- Create: `$ROOT/backend/src/lib/lock.ts`
- Create: `$ROOT/backend/src/__tests__/lib/lock.test.ts`

- [ ] **Step 8.1:** Write failing test (integration test, butuh DB hidup)

```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { prisma } from '../../config/db.js';
import { withAdvisoryLock, tryAdvisoryLock, releaseAdvisoryLock } from '../../lib/lock.js';

describe('lock lib', () => {
  afterAll(async () => prisma.$disconnect());

  it('acquires and releases a lock', async () => {
    const got = await tryAdvisoryLock(111111);
    expect(got).toBe(true);
    await releaseAdvisoryLock(111111);
  });

  it('withAdvisoryLock runs fn and releases on success', async () => {
    let ran = false;
    const result = await withAdvisoryLock(222222, async () => {
      ran = true;
      return 'ok';
    });
    expect(ran).toBe(true);
    expect(result).toBe('ok');
    // bisa lock lagi
    expect(await tryAdvisoryLock(222222)).toBe(true);
    await releaseAdvisoryLock(222222);
  });

  it('withAdvisoryLock releases on error', async () => {
    await expect(
      withAdvisoryLock(333333, async () => { throw new Error('boom'); }),
    ).rejects.toThrow('boom');
    expect(await tryAdvisoryLock(333333)).toBe(true);
    await releaseAdvisoryLock(333333);
  });
});
```

- [ ] **Step 8.2:** Run → FAIL

- [ ] **Step 8.3:** Implement `src/lib/lock.ts`

```typescript
import { prisma } from '../config/db.js';

export async function tryAdvisoryLock(key: number): Promise<boolean> {
  const rows = await prisma.$queryRaw<Array<{ got: boolean }>>`
    SELECT pg_try_advisory_lock(${key}) AS got
  `;
  return rows[0]?.got === true;
}

export async function releaseAdvisoryLock(key: number): Promise<void> {
  await prisma.$queryRaw`SELECT pg_advisory_unlock(${key})`;
}

export async function withAdvisoryLock<T>(key: number, fn: () => Promise<T>): Promise<T> {
  const got = await tryAdvisoryLock(key);
  if (!got) throw new Error(`LOCK_BUSY:${key}`);
  try {
    return await fn();
  } finally {
    await releaseAdvisoryLock(key);
  }
}
```

- [ ] **Step 8.4:** Run → PASS

- [ ] **Step 8.5:** Commit

```bash
git add backend/src/lib/lock.ts backend/src/__tests__/lib/lock.test.ts
git commit -m "feat(lib): pg_advisory_lock helpers (tryLock, withLock, release)"
```

---

## Task 9: lib/queue (job enqueue & claim)

**Files:**
- Create: `$ROOT/backend/src/lib/queue.ts`
- Create: `$ROOT/backend/src/__tests__/lib/queue.test.ts`

- [ ] **Step 9.1:** Write failing test

```typescript
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { prisma } from '../../config/db.js';
import { enqueueJob, claimNextJob, completeJob, failJob } from '../../lib/queue.js';

describe('queue lib', () => {
  beforeEach(async () => {
    await prisma.job.deleteMany({ where: { queue: 'test_q' } });
  });
  afterAll(async () => prisma.$disconnect());

  it('enqueues and claims a job', async () => {
    const job = await enqueueJob('test_q', { hello: 'world' });
    expect(job.status).toBe('PENDING');

    const claimed = await claimNextJob('test_q', 'worker-1');
    expect(claimed?.id).toBe(job.id);
    expect(claimed?.status).toBe('RUNNING');
    expect(claimed?.lockedBy).toBe('worker-1');
  });

  it('returns null when no job available', async () => {
    const claimed = await claimNextJob('test_q', 'worker-1');
    expect(claimed).toBeNull();
  });

  it('SKIP LOCKED — two workers do not claim same job', async () => {
    await enqueueJob('test_q', { i: 1 });
    const [a, b] = await Promise.all([
      claimNextJob('test_q', 'w-a'),
      claimNextJob('test_q', 'w-b'),
    ]);
    const claimedCount = [a, b].filter(Boolean).length;
    expect(claimedCount).toBe(1);
  });

  it('completeJob sets DONE', async () => {
    const job = await enqueueJob('test_q', {});
    await claimNextJob('test_q', 'w');
    await completeJob(job.id, 'result-ref-1');
    const fresh = await prisma.job.findUnique({ where: { id: job.id } });
    expect(fresh?.status).toBe('DONE');
    expect(fresh?.resultRef).toBe('result-ref-1');
  });

  it('failJob retries until maxAttempts then DEAD', async () => {
    const job = await enqueueJob('test_q', {}, { maxAttempts: 2 });
    await claimNextJob('test_q', 'w');
    await failJob(job.id, 'err1');
    let fresh = await prisma.job.findUnique({ where: { id: job.id } });
    expect(fresh?.status).toBe('PENDING');
    expect(fresh?.attempts).toBe(1);

    await claimNextJob('test_q', 'w');
    await failJob(job.id, 'err2');
    fresh = await prisma.job.findUnique({ where: { id: job.id } });
    expect(fresh?.status).toBe('DEAD');
    expect(fresh?.attempts).toBe(2);
  });
});
```

- [ ] **Step 9.2:** Run → FAIL

- [ ] **Step 9.3:** Implement `src/lib/queue.ts`

```typescript
import { prisma } from '../config/db.js';
import type { Job, Prisma } from '@prisma/client';

export interface EnqueueOptions {
  runAt?: Date;
  maxAttempts?: number;
}

export async function enqueueJob(
  queue: string,
  payload: Prisma.InputJsonValue,
  opts: EnqueueOptions = {},
): Promise<Job> {
  return prisma.job.create({
    data: {
      queue,
      payload,
      runAt: opts.runAt ?? new Date(),
      maxAttempts: opts.maxAttempts ?? 5,
    },
  });
}

export async function claimNextJob(queue: string, workerId: string): Promise<Job | null> {
  // Atomic: pakai raw SQL untuk FOR UPDATE SKIP LOCKED + UPDATE dalam 1 round-trip
  const rows = await prisma.$queryRaw<Job[]>`
    UPDATE jobs
    SET status = 'RUNNING',
        attempts = attempts + 1,
        "lockedAt" = NOW(),
        "lockedBy" = ${workerId}
    WHERE id = (
      SELECT id FROM jobs
      WHERE queue = ${queue}
        AND status = 'PENDING'
        AND "runAt" <= NOW()
      ORDER BY id
      FOR UPDATE SKIP LOCKED
      LIMIT 1
    )
    RETURNING *
  `;
  return rows[0] ?? null;
}

export async function completeJob(id: bigint, resultRef?: string): Promise<void> {
  await prisma.job.update({
    where: { id },
    data: { status: 'DONE', finishedAt: new Date(), resultRef: resultRef ?? null, lockedAt: null, lockedBy: null },
  });
}

export async function failJob(id: bigint, errorMsg: string, backoffSec = 30): Promise<void> {
  const job = await prisma.job.findUniqueOrThrow({ where: { id } });
  if (job.attempts >= job.maxAttempts) {
    await prisma.job.update({
      where: { id },
      data: { status: 'DEAD', finishedAt: new Date(), lastError: errorMsg, lockedAt: null, lockedBy: null },
    });
  } else {
    const runAt = new Date(Date.now() + backoffSec * 1000 * job.attempts); // linear backoff
    await prisma.job.update({
      where: { id },
      data: { status: 'PENDING', runAt, lastError: errorMsg, lockedAt: null, lockedBy: null },
    });
  }
}

// Reset stuck jobs (worker crashed): RUNNING > 5 menit → PENDING
export async function resetStuckJobs(staleMinutes = 5): Promise<number> {
  const cutoff = new Date(Date.now() - staleMinutes * 60_000);
  const res = await prisma.$executeRaw`
    UPDATE jobs SET status='PENDING', "lockedAt"=NULL, "lockedBy"=NULL
    WHERE status='RUNNING' AND "lockedAt" < ${cutoff}
  `;
  return Number(res);
}
```

- [ ] **Step 9.4:** Run → PASS

- [ ] **Step 9.5:** Commit

```bash
git add backend/src/lib/queue.ts backend/src/__tests__/lib/queue.test.ts
git commit -m "feat(lib): postgres-backed job queue with SKIP LOCKED, retries, dead letter"
```

---

## Task 10: lib/files (write/read /kopdes with sha256 dedup)

**Files:**
- Create: `$ROOT/backend/src/lib/files.ts`
- Create: `$ROOT/backend/src/__tests__/lib/files.test.ts`

- [ ] **Step 10.1:** Write failing test

```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { prisma } from '../../config/db.js';
import { storeFile, getFileStreamPath, softDeleteFile } from '../../lib/files.js';

const tmp = mkdtempSync(join(tmpdir(), 'kopdes-test-'));
process.env.KOPDES_DIR = tmp;

describe('files lib', () => {
  afterAll(async () => {
    rmSync(tmp, { recursive: true, force: true });
    await prisma.$disconnect();
  });

  it('stores a buffer and dedupes by sha256+owner', async () => {
    const buf = Buffer.from('hello kopdes');
    const a = await storeFile({
      buffer: buf, originalName: 'a.txt', mimeType: 'text/plain',
      ownerModule: 'test', ownerId: 'X1', uploadedBy: null, isPublic: false,
    });
    const b = await storeFile({
      buffer: buf, originalName: 'a.txt', mimeType: 'text/plain',
      ownerModule: 'test', ownerId: 'X1', uploadedBy: null, isPublic: false,
    });
    expect(a.id).toBe(b.id); // dedup
  });

  it('getFileStreamPath returns absolute path that exists', async () => {
    const f = await storeFile({
      buffer: Buffer.from('content'), originalName: 'f.bin', mimeType: 'application/octet-stream',
      ownerModule: 'test', ownerId: 'P', uploadedBy: null, isPublic: false,
    });
    const path = await getFileStreamPath(f.id);
    expect(path).toMatch(/^\//);
  });

  it('soft delete marks deletedAt', async () => {
    const f = await storeFile({
      buffer: Buffer.from('x'), originalName: 'x', mimeType: 'text/plain',
      ownerModule: 'test', ownerId: 'D', uploadedBy: null, isPublic: false,
    });
    await softDeleteFile(f.id);
    const after = await prisma.fileObject.findUnique({ where: { id: f.id } });
    expect(after?.deletedAt).not.toBeNull();
  });
});
```

- [ ] **Step 10.2:** Run → FAIL

- [ ] **Step 10.3:** Implement `src/lib/files.ts`

```typescript
import { createHash, randomBytes } from 'crypto';
import { mkdir, writeFile, access } from 'fs/promises';
import { join, extname } from 'path';
import { env } from '../config/env.js';
import { prisma } from '../config/db.js';
import type { FileObject } from '@prisma/client';

// Generator id sederhana (tanpa dependency tambahan). Format: f + 24 hex char.
function cuid(): string {
  return 'f' + randomBytes(12).toString('hex');
}

export interface StoreFileInput {
  buffer: Buffer;
  originalName: string;
  mimeType: string;
  ownerModule: string;
  ownerId: string | null;
  isPublic: boolean;
  uploadedBy: string | null;
}

export async function storeFile(input: StoreFileInput): Promise<FileObject> {
  const sha256 = createHash('sha256').update(input.buffer).digest('hex');

  // Dedup: cari FileObject existing (belum deleted) dengan sha256 + owner sama
  const existing = await prisma.fileObject.findFirst({
    where: {
      sha256,
      ownerModule: input.ownerModule,
      ownerId: input.ownerId,
      deletedAt: null,
    },
  });
  if (existing) return existing;

  // Tulis file ke /kopdes/<module>/YYYY/MM/<cuid>.<ext>
  const now = new Date();
  const yyyy = String(now.getUTCFullYear());
  const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
  const ext = extname(input.originalName) || '';
  const id = cuid();
  const relPath = join(input.ownerModule, yyyy, mm, `${id}${ext}`);
  const absPath = join(env.KOPDES_DIR, relPath);

  await mkdir(join(env.KOPDES_DIR, input.ownerModule, yyyy, mm), { recursive: true });
  await writeFile(absPath, input.buffer);

  return prisma.fileObject.create({
    data: {
      id,
      storagePath: relPath,
      originalName: input.originalName,
      mimeType: input.mimeType,
      sizeBytes: BigInt(input.buffer.length),
      sha256,
      ownerModule: input.ownerModule,
      ownerId: input.ownerId,
      isPublic: input.isPublic,
      uploadedBy: input.uploadedBy,
    },
  });
}

export async function getFileStreamPath(id: string): Promise<string | null> {
  const f = await prisma.fileObject.findUnique({ where: { id } });
  if (!f || f.deletedAt) return null;
  const absPath = join(env.KOPDES_DIR, f.storagePath);
  try {
    await access(absPath);
    return absPath;
  } catch {
    return null;
  }
}

export async function softDeleteFile(id: string): Promise<void> {
  await prisma.fileObject.update({ where: { id }, data: { deletedAt: new Date() } });
}
```

- [ ] **Step 10.4:** Run → PASS

- [ ] **Step 10.5:** Commit

```bash
git add backend/src/lib/files.ts backend/src/__tests__/lib/files.test.ts
git commit -m "feat(lib): file storage to /kopdes with sha256 dedup and soft delete"
```

---

## Task 11: lib/audit (helper untuk menulis AuditLog)

**Files:**
- Create: `$ROOT/backend/src/lib/audit.ts`
- Create: `$ROOT/backend/src/__tests__/lib/audit.test.ts`

- [ ] **Step 11.1:** Write failing test

```typescript
import { describe, it, expect, afterAll } from 'vitest';
import { prisma } from '../../config/db.js';
import { writeAudit } from '../../lib/audit.js';

describe('audit lib', () => {
  afterAll(async () => prisma.$disconnect());

  it('inserts an audit row', async () => {
    await writeAudit({
      userId: null, username: 'tester', action: 'TEST_ACT', module: 'test',
      entityId: 'E1', ip: '127.0.0.1', userAgent: 'vitest', result: 'OK',
      payload: { foo: 'bar' },
    });
    const found = await prisma.auditLog.findFirst({
      where: { action: 'TEST_ACT', entityId: 'E1' }, orderBy: { id: 'desc' },
    });
    expect(found).not.toBeNull();
    expect(found?.result).toBe('OK');
  });
});
```

- [ ] **Step 11.2:** Run → FAIL

- [ ] **Step 11.3:** Implement `src/lib/audit.ts`

```typescript
import { prisma } from '../config/db.js';
import type { Prisma } from '@prisma/client';

export interface AuditInput {
  userId: string | null;
  username: string | null;
  action: string;
  module: string;
  entityId?: string | null;
  ip?: string | null;
  userAgent?: string | null;
  payload?: Prisma.InputJsonValue | null;
  result: 'OK' | 'FAIL' | 'DENIED';
}

export async function writeAudit(input: AuditInput): Promise<void> {
  await prisma.auditLog.create({
    data: {
      userId: input.userId,
      username: input.username,
      action: input.action,
      module: input.module,
      entityId: input.entityId ?? null,
      ip: input.ip ?? null,
      userAgent: input.userAgent ?? null,
      payload: (input.payload ?? undefined) as Prisma.InputJsonValue | undefined,
      result: input.result,
    },
  });
}
```

- [ ] **Step 11.4:** Run → PASS

- [ ] **Step 11.5:** Commit

```bash
git add backend/src/lib/audit.ts backend/src/__tests__/lib/audit.test.ts
git commit -m "feat(lib): writeAudit helper for append-only audit log"
```

---

## Task 12: middleware/error (global error handler) + AppError class

**Files:**
- Create: `$ROOT/backend/src/lib/errors.ts`
- Create: `$ROOT/backend/src/middleware/error.ts`
- Create: `$ROOT/backend/src/__tests__/middleware/error.test.ts`

- [ ] **Step 12.1:** Write failing test

```typescript
import { describe, it, expect } from 'vitest';
import express from 'express';
import request from 'supertest';
import { AppError } from '../../lib/errors.js';
import { errorHandler } from '../../middleware/error.js';

describe('errorHandler', () => {
  it('returns AppError with code & status', async () => {
    const app = express();
    app.get('/boom', (_req, _res, next) => next(new AppError('CUSTOM_X', 'pesan', 418)));
    app.use(errorHandler);
    const res = await request(app).get('/boom');
    expect(res.status).toBe(418);
    expect(res.body).toEqual({ error: 'CUSTOM_X', message: 'pesan' });
  });

  it('returns 500 INTERNAL for unknown error', async () => {
    const app = express();
    app.get('/boom', () => { throw new Error('oops'); });
    app.use(errorHandler);
    const res = await request(app).get('/boom');
    expect(res.status).toBe(500);
    expect(res.body.error).toBe('INTERNAL');
  });
});
```

- [ ] **Step 12.2:** Run → FAIL

- [ ] **Step 12.3:** Implement `src/lib/errors.ts`

```typescript
export class AppError extends Error {
  constructor(public code: string, message: string, public status = 400, public details?: unknown) {
    super(message);
    this.name = 'AppError';
  }
}
```

- [ ] **Step 12.4:** Implement `src/middleware/error.ts`

```typescript
import type { ErrorRequestHandler } from 'express';
import { ZodError } from 'zod';
import { AppError } from '../lib/errors.js';

export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
  if (err instanceof AppError) {
    return res.status(err.status).json({ error: err.code, message: err.message, details: err.details });
  }
  if (err instanceof ZodError) {
    return res.status(400).json({ error: 'VALIDATION', message: 'Invalid input', details: err.issues });
  }
  console.error('[error]', err);
  return res.status(500).json({ error: 'INTERNAL', message: 'Internal server error' });
};
```

- [ ] **Step 12.5:** Run → PASS

- [ ] **Step 12.6:** Wire errorHandler ke `src/server.ts`. Edit `createApp()`:

```typescript
// tambah import:
import { errorHandler } from './middleware/error.js';

// ganti baris 404 menjadi:
  app.use((_req, res) => res.status(404).json({ error: 'NOT_FOUND' }));
  app.use(errorHandler);
```

- [ ] **Step 12.7:** Commit

```bash
git add backend/src/lib/errors.ts backend/src/middleware/error.ts backend/src/__tests__/middleware/error.test.ts backend/src/server.ts
git commit -m "feat(middleware): global error handler with AppError and ZodError mapping"
```

---

## Task 13: middleware/auth (JWT decode, attach user)

**Files:**
- Create: `$ROOT/backend/src/middleware/auth.ts`
- Create: `$ROOT/backend/src/__tests__/middleware/auth.test.ts`

- [ ] **Step 13.1:** Write failing test

```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
import request from 'supertest';
import { prisma } from '../../config/db.js';
import { signAccessToken } from '../../lib/jwt.js';
import { hashPassword } from '../../lib/hash.js';
import { authenticate } from '../../middleware/auth.js';
import { errorHandler } from '../../middleware/error.js';

let userId: string;
let roleId: string;
let token: string;

beforeAll(async () => {
  const role = await prisma.role.upsert({
    where: { name: 'TEST_ROLE' },
    update: {}, create: { name: 'TEST_ROLE' },
  });
  roleId = role.id;
  const user = await prisma.user.create({
    data: { email: 'auth-test@x', username: 'authtest', fullName: 'AT', roleId,
            passwordHash: await hashPassword('xx') },
  });
  userId = user.id;
  token = await signAccessToken({ sub: userId, roleId });
});

afterAll(async () => {
  await prisma.user.delete({ where: { id: userId } });
  await prisma.role.delete({ where: { id: roleId } });
  await prisma.$disconnect();
});

describe('authenticate middleware', () => {
  const app = express();
  app.get('/me', authenticate, (req, res) => res.json({ userId: (req as any).user.id }));
  app.use(errorHandler);

  it('rejects without header', async () => {
    const res = await request(app).get('/me');
    expect(res.status).toBe(401);
  });

  it('accepts valid Bearer', async () => {
    const res = await request(app).get('/me').set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(200);
    expect(res.body.userId).toBe(userId);
  });

  it('rejects invalid token', async () => {
    const res = await request(app).get('/me').set('Authorization', 'Bearer xxx');
    expect(res.status).toBe(401);
  });
});
```

- [ ] **Step 13.2:** Run → FAIL

- [ ] **Step 13.3:** Implement `src/middleware/auth.ts`

```typescript
import type { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../lib/jwt.js';
import { prisma } from '../config/db.js';
import { AppError } from '../lib/errors.js';

export interface AuthUser {
  id: string;
  username: string;
  email: string;
  fullName: string;
  roleId: string;
  roleName: string;
  permissions: Set<string>;       // "module:action"
  unitUsahaId: string | null;
  mustChangePass: boolean;
}

declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace Express {
    interface Request { user?: AuthUser }
  }
}

// in-memory permission cache: roleId -> {perms, expiresAt}
const cache = new Map<string, { perms: Set<string>; expiresAt: number }>();
const TTL_MS = 60_000;

async function loadPermissions(roleId: string): Promise<Set<string>> {
  const cached = cache.get(roleId);
  if (cached && cached.expiresAt > Date.now()) return cached.perms;
  const rps = await prisma.rolePermission.findMany({
    where: { roleId }, include: { permission: true },
  });
  const perms = new Set(rps.map((rp) => `${rp.permission.module}:${rp.permission.action}`));
  cache.set(roleId, { perms, expiresAt: Date.now() + TTL_MS });
  return perms;
}

export function invalidatePermissionCache(roleId?: string): void {
  if (roleId) cache.delete(roleId);
  else cache.clear();
}

export async function authenticate(req: Request, _res: Response, next: NextFunction): Promise<void> {
  try {
    const header = req.headers.authorization;
    if (!header?.startsWith('Bearer ')) throw new AppError('UNAUTHENTICATED', 'Missing token', 401);
    const token = header.slice(7);
    let payload;
    try { payload = await verifyAccessToken(token); }
    catch { throw new AppError('UNAUTHENTICATED', 'Invalid token', 401); }
    const user = await prisma.user.findUnique({
      where: { id: payload.sub }, include: { role: true },
    });
    if (!user || !user.isActive) throw new AppError('UNAUTHENTICATED', 'User inactive', 401);
    const permissions = await loadPermissions(user.roleId);
    req.user = {
      id: user.id, username: user.username, email: user.email, fullName: user.fullName,
      roleId: user.roleId, roleName: user.role.name, permissions,
      unitUsahaId: user.unitUsahaId, mustChangePass: user.mustChangePass,
    };
    next();
  } catch (e) { next(e); }
}
```

- [ ] **Step 13.4:** Run → PASS

- [ ] **Step 13.5:** Commit

```bash
git add backend/src/middleware/auth.ts backend/src/__tests__/middleware/auth.test.ts
git commit -m "feat(middleware): authenticate JWT and attach AuthUser with cached permissions"
```

---

## Task 14: middleware/rbac (requirePermission)

**Files:**
- Create: `$ROOT/backend/src/middleware/rbac.ts`
- Create: `$ROOT/backend/src/__tests__/middleware/rbac.test.ts`

- [ ] **Step 14.1:** Write failing test

```typescript
import { describe, it, expect } from 'vitest';
import express from 'express';
import request from 'supertest';
import { requirePermission } from '../../middleware/rbac.js';
import { errorHandler } from '../../middleware/error.js';

function mockAuth(perms: string[], roleName = 'CUSTOM') {
  return (req: any, _res: any, next: any) => {
    req.user = {
      id: 'u', username: 'u', email: 'e', fullName: 'f', roleId: 'r', roleName,
      permissions: new Set(perms), unitUsahaId: null, mustChangePass: false,
    };
    next();
  };
}

describe('requirePermission', () => {
  it('allows when permission present', async () => {
    const app = express();
    app.get('/x', mockAuth(['user:read']), requirePermission('user', 'read'),
      (_req, res) => res.json({ ok: true }));
    app.use(errorHandler);
    const res = await request(app).get('/x');
    expect(res.status).toBe(200);
  });

  it('denies 403 when missing', async () => {
    const app = express();
    app.get('/x', mockAuth([]), requirePermission('user', 'read'),
      (_req, res) => res.json({ ok: true }));
    app.use(errorHandler);
    const res = await request(app).get('/x');
    expect(res.status).toBe(403);
  });

  it('SUPER_ADMIN bypasses', async () => {
    const app = express();
    app.get('/x', mockAuth([], 'SUPER_ADMIN'), requirePermission('user', 'delete'),
      (_req, res) => res.json({ ok: true }));
    app.use(errorHandler);
    const res = await request(app).get('/x');
    expect(res.status).toBe(200);
  });
});
```

- [ ] **Step 14.2:** Run → FAIL

- [ ] **Step 14.3:** Implement `src/middleware/rbac.ts`

```typescript
import type { Request, Response, NextFunction } from 'express';
import { AppError } from '../lib/errors.js';
import { writeAudit } from '../lib/audit.js';

export function requirePermission(module: string, action: string) {
  return async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
    try {
      const u = req.user;
      if (!u) throw new AppError('UNAUTHENTICATED', 'Not authenticated', 401);
      if (u.roleName === 'SUPER_ADMIN') return next();
      const need = `${module}:${action}`;
      if (!u.permissions.has(need)) {
        await writeAudit({
          userId: u.id, username: u.username, action: 'PERMISSION_DENIED',
          module, ip: req.ip ?? null, userAgent: req.get('user-agent') ?? null,
          payload: { need, path: req.path, method: req.method }, result: 'DENIED',
        });
        throw new AppError('FORBIDDEN', `Missing permission ${need}`, 403);
      }
      next();
    } catch (e) { next(e); }
  };
}
```

- [ ] **Step 14.4:** Run → PASS

- [ ] **Step 14.5:** Commit

```bash
git add backend/src/middleware/rbac.ts backend/src/__tests__/middleware/rbac.test.ts
git commit -m "feat(middleware): requirePermission RBAC with SUPER_ADMIN bypass and audit on deny"
```

---

## Task 15: middleware/idempotency

**Files:**
- Create: `$ROOT/backend/src/middleware/idempotency.ts`
- Create: `$ROOT/backend/src/__tests__/middleware/idempotency.test.ts`

- [ ] **Step 15.1:** Write failing test

```typescript
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import express from 'express';
import request from 'supertest';
import { prisma } from '../../config/db.js';
import { idempotencyGuard } from '../../middleware/idempotency.js';
import { errorHandler } from '../../middleware/error.js';

let counter = 0;
const app = express();
app.use(express.json());
app.post('/charge', idempotencyGuard(), (req, res) => {
  counter += 1;
  res.status(201).json({ counter, body: req.body });
});
app.use(errorHandler);

describe('idempotencyGuard', () => {
  beforeEach(async () => {
    counter = 0;
    await prisma.idempotencyKey.deleteMany({ where: { endpoint: { contains: '/charge' } } });
  });
  afterAll(async () => prisma.$disconnect());

  it('rejects without key', async () => {
    const res = await request(app).post('/charge').send({ amt: 100 });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('IDEMPOTENCY_KEY_REQUIRED');
  });

  it('returns cached response on replay', async () => {
    const key = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa';
    const r1 = await request(app).post('/charge').set('Idempotency-Key', key).send({ amt: 100 });
    const r2 = await request(app).post('/charge').set('Idempotency-Key', key).send({ amt: 100 });
    expect(r1.body).toEqual(r2.body);
    expect(counter).toBe(1);
  });

  it('rejects key reuse with different payload', async () => {
    const key = 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb';
    await request(app).post('/charge').set('Idempotency-Key', key).send({ amt: 100 });
    const r = await request(app).post('/charge').set('Idempotency-Key', key).send({ amt: 200 });
    expect(r.status).toBe(422);
    expect(r.body.error).toBe('IDEMPOTENCY_KEY_REUSED');
  });
});
```

- [ ] **Step 15.2:** Run → FAIL

- [ ] **Step 15.3:** Implement `src/middleware/idempotency.ts`

```typescript
import type { Request, Response, NextFunction } from 'express';
import { createHash } from 'crypto';
import { prisma } from '../config/db.js';
import { AppError } from '../lib/errors.js';

const TTL_HOURS = 24;
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

function hashRequest(method: string, path: string, body: unknown): string {
  return createHash('sha256').update(`${method}|${path}|${JSON.stringify(body ?? {})}`).digest('hex');
}

export function idempotencyGuard() {
  return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    try {
      const key = req.get('Idempotency-Key');
      if (!key) throw new AppError('IDEMPOTENCY_KEY_REQUIRED', 'Header Idempotency-Key is required', 400);
      if (!UUID_V4.test(key)) throw new AppError('IDEMPOTENCY_KEY_INVALID', 'Idempotency-Key must be UUID v4', 400);

      const endpoint = `${req.method} ${req.path}`;
      const requestHash = hashRequest(req.method, req.path, req.body);

      const existing = await prisma.idempotencyKey.findUnique({ where: { key } });
      if (existing) {
        if (existing.requestHash !== requestHash) {
          throw new AppError('IDEMPOTENCY_KEY_REUSED', 'Key reused with different payload', 422);
        }
        return res.status(existing.statusCode).json(existing.response).end() as unknown as void;
      }

      // Intercept res.json untuk simpan response
      const userId = req.user?.id ?? null;
      const origJson = res.json.bind(res);
      let captured: unknown;
      res.json = (body: unknown) => { captured = body; return origJson(body); };

      res.on('finish', () => {
        if (res.statusCode >= 200 && res.statusCode < 300 && captured !== undefined) {
          prisma.idempotencyKey.create({
            data: {
              key, userId, endpoint, requestHash, statusCode: res.statusCode,
              response: captured as never,
              expiresAt: new Date(Date.now() + TTL_HOURS * 3600_000),
            },
          }).catch((e) => console.error('[idempotency] save failed', e));
        }
      });

      next();
    } catch (e) { next(e); }
  };
}
```

- [ ] **Step 15.4:** Run → PASS

- [ ] **Step 15.5:** Commit

```bash
git add backend/src/middleware/idempotency.ts backend/src/__tests__/middleware/idempotency.test.ts
git commit -m "feat(middleware): idempotency guard with sha256 request hash and TTL 24h"
```

---

## Task 16: middleware/request-context (ip, requestId, audit helper)

**Files:**
- Create: `$ROOT/backend/src/middleware/request-context.ts`
- Modify: `$ROOT/backend/src/server.ts`

- [ ] **Step 16.1:** Implement `src/middleware/request-context.ts`

```typescript
import type { Request, Response, NextFunction } from 'express';
import { randomBytes } from 'crypto';

export function requestContext(req: Request, res: Response, next: NextFunction): void {
  const id = req.get('x-request-id') ?? randomBytes(8).toString('hex');
  res.setHeader('x-request-id', id);
  (req as Request & { requestId: string }).requestId = id;
  next();
}
```

- [ ] **Step 16.2:** Wire ke `src/server.ts` — di `createApp()`, sebelum `app.use('/api/health', ...)`:

```typescript
import { requestContext } from './middleware/request-context.js';
// ...
  app.use(requestContext);
  app.set('trust proxy', 1); // Apache di depan, supaya req.ip benar
```

- [ ] **Step 16.3:** Smoke test manual

```bash
cd $ROOT/backend && npm run dev
# di shell lain:
curl -i http://127.0.0.1:4100/api/health
# Expected: header x-request-id muncul
```

- [ ] **Step 16.4:** Commit

```bash
git add backend/src/middleware/request-context.ts backend/src/server.ts
git commit -m "feat(middleware): request-context with x-request-id and trust proxy"
```

---

## Task 17: modules/auth — login, refresh, logout, change-password, me

**Files:**
- Create: `$ROOT/backend/src/modules/auth/auth.service.ts`
- Create: `$ROOT/backend/src/modules/auth/auth.controller.ts`
- Create: `$ROOT/backend/src/modules/auth/auth.schema.ts`
- Create: `$ROOT/backend/src/__tests__/modules/auth.test.ts`
- Modify: `$ROOT/backend/src/server.ts`

- [ ] **Step 17.1:** Create `auth.schema.ts`

```typescript
import { z } from 'zod';

export const LoginSchema = z.object({
  usernameOrEmail: z.string().min(1),
  password: z.string().min(1),
});

export const ChangePasswordSchema = z.object({
  currentPassword: z.string().min(1),
  newPassword: z.string().min(8).max(128),
});
```

- [ ] **Step 17.2:** Create `auth.service.ts`

```typescript
import { prisma } from '../../config/db.js';
import { hashPassword, verifyPassword } from '../../lib/hash.js';
import { signAccessToken, generateRefreshTokenRaw, hashRefreshToken } from '../../lib/jwt.js';
import { writeAudit } from '../../lib/audit.js';
import { AppError } from '../../lib/errors.js';

const LOCKOUT_THRESHOLD = 5;
const LOCKOUT_MINUTES = 15;
const REFRESH_TTL_DAYS = 7;

export interface LoginContext {
  ip: string | null;
  userAgent: string | null;
}

export async function login(
  usernameOrEmail: string,
  password: string,
  ctx: LoginContext,
): Promise<{ accessToken: string; refreshTokenRaw: string; user: any; mustChangePass: boolean }> {
  const user = await prisma.user.findFirst({
    where: { OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }] },
    include: { role: true },
  });

  const failAndAudit = async (reason: string, userId: string | null, username: string | null) => {
    await writeAudit({ userId, username, action: 'LOGIN_FAIL', module: 'auth',
      ip: ctx.ip, userAgent: ctx.userAgent, payload: { reason }, result: 'FAIL' });
    throw new AppError('AUTH_INVALID', 'Username atau password salah', 401);
  };

  if (!user) return failAndAudit('user_not_found', null, usernameOrEmail) as never;
  if (!user.isActive) return failAndAudit('inactive', user.id, user.username) as never;
  if (user.lockedUntil && user.lockedUntil > new Date()) {
    throw new AppError('AUTH_LOCKED', `Akun terkunci sampai ${user.lockedUntil.toISOString()}`, 423);
  }

  const ok = await verifyPassword(password, user.passwordHash);
  if (!ok) {
    const attempts = user.failedAttempts + 1;
    const lockedUntil = attempts >= LOCKOUT_THRESHOLD ? new Date(Date.now() + LOCKOUT_MINUTES * 60_000) : null;
    await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: attempts, lockedUntil } });
    return failAndAudit('wrong_password', user.id, user.username) as never;
  }

  // success
  await prisma.user.update({
    where: { id: user.id },
    data: { failedAttempts: 0, lockedUntil: null, lastLoginAt: new Date(), lastLoginIp: ctx.ip },
  });

  const accessToken = await signAccessToken({ sub: user.id, roleId: user.roleId });
  const refreshTokenRaw = generateRefreshTokenRaw();
  await prisma.refreshToken.create({
    data: {
      tokenHash: hashRefreshToken(refreshTokenRaw),
      userId: user.id,
      expiresAt: new Date(Date.now() + REFRESH_TTL_DAYS * 86400_000),
      ip: ctx.ip, userAgent: ctx.userAgent,
    },
  });

  await writeAudit({ userId: user.id, username: user.username, action: 'LOGIN_OK', module: 'auth',
    ip: ctx.ip, userAgent: ctx.userAgent, result: 'OK' });

  return {
    accessToken, refreshTokenRaw,
    user: { id: user.id, username: user.username, email: user.email, fullName: user.fullName,
            role: { id: user.role.id, name: user.role.name }, unitUsahaId: user.unitUsahaId },
    mustChangePass: user.mustChangePass,
  };
}

export async function refreshTokens(
  rawToken: string, ctx: LoginContext,
): Promise<{ accessToken: string; refreshTokenRaw: string }> {
  const tokenHash = hashRefreshToken(rawToken);
  const rt = await prisma.refreshToken.findUnique({ where: { tokenHash } });
  if (!rt || rt.revokedAt || rt.expiresAt < new Date()) {
    throw new AppError('AUTH_REFRESH_INVALID', 'Refresh token invalid', 401);
  }
  const user = await prisma.user.findUniqueOrThrow({ where: { id: rt.userId } });
  if (!user.isActive) throw new AppError('AUTH_INACTIVE', 'User inactive', 401);

  const newRaw = generateRefreshTokenRaw();
  const newRt = await prisma.refreshToken.create({
    data: {
      tokenHash: hashRefreshToken(newRaw),
      userId: user.id,
      expiresAt: new Date(Date.now() + REFRESH_TTL_DAYS * 86400_000),
      ip: ctx.ip, userAgent: ctx.userAgent,
    },
  });
  await prisma.refreshToken.update({
    where: { id: rt.id }, data: { revokedAt: new Date(), replacedBy: newRt.id },
  });

  const accessToken = await signAccessToken({ sub: user.id, roleId: user.roleId });
  return { accessToken, refreshTokenRaw: newRaw };
}

export async function logout(rawToken: string | undefined): Promise<void> {
  if (!rawToken) return;
  const tokenHash = hashRefreshToken(rawToken);
  await prisma.refreshToken.updateMany({
    where: { tokenHash, revokedAt: null }, data: { revokedAt: new Date() },
  });
}

export async function changePassword(
  userId: string, currentPassword: string, newPassword: string,
): Promise<void> {
  const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
  const ok = await verifyPassword(currentPassword, user.passwordHash);
  if (!ok) throw new AppError('AUTH_WRONG_PASSWORD', 'Password saat ini salah', 400);
  const newHash = await hashPassword(newPassword);
  await prisma.user.update({
    where: { id: userId },
    data: { passwordHash: newHash, mustChangePass: false },
  });
  // Revoke semua refresh token user (force re-login di semua device)
  await prisma.refreshToken.updateMany({
    where: { userId, revokedAt: null }, data: { revokedAt: new Date() },
  });
  await writeAudit({ userId, username: user.username, action: 'PASSWORD_CHANGED', module: 'auth',
    ip: null, userAgent: null, result: 'OK' });
}
```

- [ ] **Step 17.3:** Create `auth.controller.ts`

```typescript
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { env } from '../../config/env.js';
import { login, refreshTokens, logout, changePassword } from './auth.service.js';
import { LoginSchema, ChangePasswordSchema } from './auth.schema.js';
import { authenticate } from '../../middleware/auth.js';

export const authRouter = Router();
const loginLimiter = rateLimit({ windowMs: 60_000, max: env.RATE_LIMIT_LOGIN_PER_MIN, standardHeaders: true });

function setRefreshCookie(res: any, raw: string): void {
  res.cookie('refresh_token', raw, {
    httpOnly: true, secure: env.COOKIE_SECURE, sameSite: 'lax',
    maxAge: 7 * 86400_000, path: '/api/auth',
  });
}

authRouter.post('/login', loginLimiter, async (req, res, next) => {
  try {
    const data = LoginSchema.parse(req.body);
    const result = await login(data.usernameOrEmail, data.password, {
      ip: req.ip ?? null, userAgent: req.get('user-agent') ?? null,
    });
    setRefreshCookie(res, result.refreshTokenRaw);
    res.json({ accessToken: result.accessToken, user: result.user, mustChangePass: result.mustChangePass });
  } catch (e) { next(e); }
});

authRouter.post('/refresh', async (req, res, next) => {
  try {
    const raw = req.cookies?.refresh_token;
    if (!raw) return res.status(401).json({ error: 'AUTH_REFRESH_MISSING' });
    const { accessToken, refreshTokenRaw } = await refreshTokens(raw, {
      ip: req.ip ?? null, userAgent: req.get('user-agent') ?? null,
    });
    setRefreshCookie(res, refreshTokenRaw);
    res.json({ accessToken });
  } catch (e) { next(e); }
});

authRouter.post('/logout', async (req, res, next) => {
  try {
    await logout(req.cookies?.refresh_token);
    res.clearCookie('refresh_token', { path: '/api/auth' });
    res.json({ ok: true });
  } catch (e) { next(e); }
});

authRouter.post('/change-password', authenticate, async (req, res, next) => {
  try {
    const data = ChangePasswordSchema.parse(req.body);
    await changePassword(req.user!.id, data.currentPassword, data.newPassword);
    res.json({ ok: true });
  } catch (e) { next(e); }
});

authRouter.get('/me', authenticate, async (req, res) => {
  const u = req.user!;
  res.json({
    id: u.id, username: u.username, email: u.email, fullName: u.fullName,
    role: { id: u.roleId, name: u.roleName },
    permissions: Array.from(u.permissions),
    unitUsahaId: u.unitUsahaId, mustChangePass: u.mustChangePass,
  });
});
```

- [ ] **Step 17.4:** Mount di `src/server.ts` — tambah:

```typescript
import { authRouter } from './modules/auth/auth.controller.js';
// ...
  app.use('/api/auth', authRouter);
```

- [ ] **Step 17.5:** Write integration test `__tests__/modules/auth.test.ts`

```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { prisma } from '../../config/db.js';
import { hashPassword } from '../../lib/hash.js';
import { createApp } from '../../server.js';

let app: any;
let roleId: string;

beforeAll(async () => {
  app = await createApp();
  const role = await prisma.role.upsert({ where: { name: 'AUTH_TEST_ROLE' },
    update: {}, create: { name: 'AUTH_TEST_ROLE' } });
  roleId = role.id;
  await prisma.user.create({
    data: { email: 'login@x.id', username: 'loginuser', fullName: 'L',
      passwordHash: await hashPassword('correcthorse'), roleId },
  });
});

afterAll(async () => {
  await prisma.refreshToken.deleteMany({ where: { user: { username: 'loginuser' } } });
  await prisma.auditLog.deleteMany({ where: { username: 'loginuser' } });
  await prisma.user.deleteMany({ where: { username: 'loginuser' } });
  await prisma.role.delete({ where: { id: roleId } });
  await prisma.$disconnect();
});

describe('POST /api/auth/login', () => {
  it('200 on correct credentials', async () => {
    const res = await request(app).post('/api/auth/login')
      .send({ usernameOrEmail: 'loginuser', password: 'correcthorse' });
    expect(res.status).toBe(200);
    expect(res.body.accessToken).toBeTruthy();
    expect(res.headers['set-cookie']?.[0]).toMatch(/refresh_token=/);
  });

  it('401 on wrong password', async () => {
    const res = await request(app).post('/api/auth/login')
      .send({ usernameOrEmail: 'loginuser', password: 'wrong' });
    expect(res.status).toBe(401);
  });

  it('locks after 5 failed attempts (cleanup state)', async () => {
    await prisma.user.update({ where: { username: 'loginuser' }, data: { failedAttempts: 4, lockedUntil: null } });
    await request(app).post('/api/auth/login').send({ usernameOrEmail: 'loginuser', password: 'wrong' });
    const u = await prisma.user.findUniqueOrThrow({ where: { username: 'loginuser' } });
    expect(u.lockedUntil).not.toBeNull();
    // reset
    await prisma.user.update({ where: { username: 'loginuser' }, data: { failedAttempts: 0, lockedUntil: null } });
  });
});

describe('GET /api/auth/me', () => {
  it('returns user when token valid', async () => {
    const login = await request(app).post('/api/auth/login')
      .send({ usernameOrEmail: 'loginuser', password: 'correcthorse' });
    const res = await request(app).get('/api/auth/me')
      .set('Authorization', `Bearer ${login.body.accessToken}`);
    expect(res.status).toBe(200);
    expect(res.body.username).toBe('loginuser');
    expect(Array.isArray(res.body.permissions)).toBe(true);
  });
});
```

- [ ] **Step 17.6:** Run tests → PASS

- [ ] **Step 17.7:** Commit

```bash
git add backend/src/modules/auth backend/src/__tests__/modules/auth.test.ts backend/src/server.ts
git commit -m "feat(auth): login/refresh/logout/change-password/me with lockout and rate-limit"
```

---

## Task 18: modules/user — CRUD + reset password + unlock

**Files:**
- Create: `$ROOT/backend/src/modules/user/user.schema.ts`
- Create: `$ROOT/backend/src/modules/user/user.service.ts`
- Create: `$ROOT/backend/src/modules/user/user.controller.ts`
- Create: `$ROOT/backend/src/__tests__/modules/user.test.ts`
- Modify: `$ROOT/backend/src/server.ts`

- [ ] **Step 18.1:** Create `user.schema.ts`

```typescript
import { z } from 'zod';

export const CreateUserSchema = z.object({
  username: z.string().min(3).max(40).regex(/^[a-zA-Z0-9._-]+$/),
  email: z.string().email(),
  fullName: z.string().min(1).max(120),
  phone: z.string().max(30).optional().nullable(),
  password: z.string().min(8).max(128),
  roleId: z.string(),
  unitUsahaId: z.string().nullable().optional(),
  mustChangePass: z.boolean().optional().default(true),
});

export const UpdateUserSchema = z.object({
  email: z.string().email().optional(),
  fullName: z.string().min(1).max(120).optional(),
  phone: z.string().max(30).nullable().optional(),
  isActive: z.boolean().optional(),
  roleId: z.string().optional(),
  unitUsahaId: z.string().nullable().optional(),
});

export const ListQuerySchema = z.object({
  search: z.string().optional(),
  roleId: z.string().optional(),
  active: z.enum(['true','false']).optional(),
  page: z.coerce.number().min(1).default(1),
  pageSize: z.coerce.number().min(1).max(100).default(20),
});
```

- [ ] **Step 18.2:** Create `user.service.ts`

```typescript
import { prisma } from '../../config/db.js';
import { hashPassword } from '../../lib/hash.js';
import { writeAudit } from '../../lib/audit.js';
import { AppError } from '../../lib/errors.js';
import { randomBytes } from 'crypto';

function publicUser(u: any) {
  return {
    id: u.id, username: u.username, email: u.email, fullName: u.fullName, phone: u.phone,
    isActive: u.isActive, mustChangePass: u.mustChangePass, lastLoginAt: u.lastLoginAt,
    lockedUntil: u.lockedUntil, roleId: u.roleId, unitUsahaId: u.unitUsahaId,
    role: u.role ? { id: u.role.id, name: u.role.name } : undefined,
    createdAt: u.createdAt, updatedAt: u.updatedAt,
  };
}

export async function listUsers(q: { search?: string; roleId?: string; active?: 'true'|'false'; page: number; pageSize: number; }) {
  const where: any = {};
  if (q.search) where.OR = [
    { username: { contains: q.search, mode: 'insensitive' } },
    { email: { contains: q.search, mode: 'insensitive' } },
    { fullName: { contains: q.search, mode: 'insensitive' } },
  ];
  if (q.roleId) where.roleId = q.roleId;
  if (q.active === 'true') where.isActive = true;
  if (q.active === 'false') where.isActive = false;

  const [total, rows] = await Promise.all([
    prisma.user.count({ where }),
    prisma.user.findMany({
      where, include: { role: true },
      orderBy: { createdAt: 'desc' },
      skip: (q.page - 1) * q.pageSize, take: q.pageSize,
    }),
  ]);
  return { total, page: q.page, pageSize: q.pageSize, items: rows.map(publicUser) };
}

export async function getUser(id: string) {
  const u = await prisma.user.findUnique({ where: { id }, include: { role: true } });
  if (!u) throw new AppError('NOT_FOUND', 'User tidak ditemukan', 404);
  return publicUser(u);
}

export async function createUser(input: any, actor: { id: string; username: string }) {
  const role = await prisma.role.findUnique({ where: { id: input.roleId } });
  if (!role) throw new AppError('ROLE_NOT_FOUND', 'Role tidak ditemukan', 400);
  const passwordHash = await hashPassword(input.password);
  try {
    const created = await prisma.user.create({
      data: {
        username: input.username, email: input.email, fullName: input.fullName,
        phone: input.phone ?? null, passwordHash, roleId: input.roleId,
        unitUsahaId: input.unitUsahaId ?? null,
        mustChangePass: input.mustChangePass ?? true,
        createdBy: actor.id,
      },
      include: { role: true },
    });
    await writeAudit({ userId: actor.id, username: actor.username, action: 'USER_CREATE', module: 'user',
      entityId: created.id, payload: { username: created.username }, result: 'OK' });
    return publicUser(created);
  } catch (e: any) {
    if (e.code === 'P2002') throw new AppError('DUPLICATE', 'Username atau email sudah dipakai', 409);
    throw e;
  }
}

export async function updateUser(id: string, input: any, actor: { id: string; username: string }) {
  if (input.roleId) {
    const role = await prisma.role.findUnique({ where: { id: input.roleId } });
    if (!role) throw new AppError('ROLE_NOT_FOUND', 'Role tidak ditemukan', 400);
  }
  try {
    const updated = await prisma.user.update({ where: { id }, data: input, include: { role: true } });
    await writeAudit({ userId: actor.id, username: actor.username, action: 'USER_UPDATE', module: 'user',
      entityId: id, payload: { fields: Object.keys(input) }, result: 'OK' });
    return publicUser(updated);
  } catch (e: any) {
    if (e.code === 'P2025') throw new AppError('NOT_FOUND', 'User tidak ditemukan', 404);
    if (e.code === 'P2002') throw new AppError('DUPLICATE', 'Email sudah dipakai', 409);
    throw e;
  }
}

export async function deactivateUser(id: string, actor: { id: string; username: string }) {
  if (id === actor.id) throw new AppError('SELF_FORBIDDEN', 'Tidak boleh menonaktifkan diri sendiri', 400);
  const target = await prisma.user.findUnique({ where: { id }, include: { role: true } });
  if (!target) throw new AppError('NOT_FOUND', 'User tidak ditemukan', 404);
  if (target.role.name === 'SUPER_ADMIN') {
    const superAdmins = await prisma.user.count({ where: { roleId: target.roleId, isActive: true } });
    if (superAdmins <= 1) throw new AppError('LAST_SUPER_ADMIN', 'Tidak boleh menonaktifkan SUPER_ADMIN terakhir', 400);
  }
  await prisma.user.update({ where: { id }, data: { isActive: false } });
  await writeAudit({ userId: actor.id, username: actor.username, action: 'USER_DEACTIVATE',
    module: 'user', entityId: id, result: 'OK' });
}

export async function resetUserPassword(id: string, actor: { id: string; username: string }): Promise<string> {
  const charset = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#%*';
  const bytes = randomBytes(16);
  const password = Array.from(bytes, (b) => charset[b % charset.length]).join('');
  const passwordHash = await hashPassword(password);
  await prisma.user.update({ where: { id }, data: { passwordHash, mustChangePass: true, failedAttempts: 0, lockedUntil: null } });
  await prisma.refreshToken.updateMany({ where: { userId: id, revokedAt: null }, data: { revokedAt: new Date() } });
  await writeAudit({ userId: actor.id, username: actor.username, action: 'USER_RESET_PASSWORD',
    module: 'user', entityId: id, result: 'OK' });
  return password;
}

export async function unlockUser(id: string, actor: { id: string; username: string }): Promise<void> {
  await prisma.user.update({ where: { id }, data: { failedAttempts: 0, lockedUntil: null } });
  await writeAudit({ userId: actor.id, username: actor.username, action: 'USER_UNLOCK',
    module: 'user', entityId: id, result: 'OK' });
}
```

- [ ] **Step 18.3:** Create `user.controller.ts`

```typescript
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.js';
import { requirePermission } from '../../middleware/rbac.js';
import { CreateUserSchema, UpdateUserSchema, ListQuerySchema } from './user.schema.js';
import * as svc from './user.service.js';

export const userRouter = Router();
userRouter.use(authenticate);

userRouter.get('/', requirePermission('user','read'), async (req, res, next) => {
  try {
    const q = ListQuerySchema.parse(req.query);
    res.json(await svc.listUsers(q));
  } catch (e) { next(e); }
});

userRouter.post('/', requirePermission('user','create'), async (req, res, next) => {
  try {
    const data = CreateUserSchema.parse(req.body);
    const u = req.user!;
    res.status(201).json(await svc.createUser(data, { id: u.id, username: u.username }));
  } catch (e) { next(e); }
});

userRouter.get('/:id', requirePermission('user','read'), async (req, res, next) => {
  try { res.json(await svc.getUser(req.params.id)); } catch (e) { next(e); }
});

userRouter.patch('/:id', requirePermission('user','update'), async (req, res, next) => {
  try {
    const data = UpdateUserSchema.parse(req.body);
    const u = req.user!;
    res.json(await svc.updateUser(req.params.id, data, { id: u.id, username: u.username }));
  } catch (e) { next(e); }
});

userRouter.delete('/:id', requirePermission('user','delete'), async (req, res, next) => {
  try {
    const u = req.user!;
    await svc.deactivateUser(req.params.id, { id: u.id, username: u.username });
    res.json({ ok: true });
  } catch (e) { next(e); }
});

userRouter.post('/:id/reset-password', requirePermission('user','update'), async (req, res, next) => {
  try {
    const u = req.user!;
    const password = await svc.resetUserPassword(req.params.id, { id: u.id, username: u.username });
    res.json({ password });
  } catch (e) { next(e); }
});

userRouter.post('/:id/unlock', requirePermission('user','update'), async (req, res, next) => {
  try {
    const u = req.user!;
    await svc.unlockUser(req.params.id, { id: u.id, username: u.username });
    res.json({ ok: true });
  } catch (e) { next(e); }
});
```

- [ ] **Step 18.4:** Mount di `src/server.ts`:

```typescript
import { userRouter } from './modules/user/user.controller.js';
// ...
  app.use('/api/users', userRouter);
```

- [ ] **Step 18.5:** Write integration test `__tests__/modules/user.test.ts`

```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { prisma } from '../../config/db.js';
import { hashPassword } from '../../lib/hash.js';
import { signAccessToken } from '../../lib/jwt.js';
import { createApp } from '../../server.js';

let app: any;
let adminToken: string;
let adminId: string;
let testRoleId: string;

beforeAll(async () => {
  app = await createApp();
  // assume SUPER_ADMIN sudah ada dari seed
  const superRole = await prisma.role.findUniqueOrThrow({ where: { name: 'SUPER_ADMIN' } });
  const admin = await prisma.user.create({
    data: { email: 'usertest-admin@x', username: 'usertest_admin', fullName: 'A',
      passwordHash: await hashPassword('x'), roleId: superRole.id },
  });
  adminId = admin.id;
  adminToken = await signAccessToken({ sub: admin.id, roleId: superRole.id });
  testRoleId = (await prisma.role.create({ data: { name: 'USERTEST_ROLE' } })).id;
});

afterAll(async () => {
  await prisma.auditLog.deleteMany({ where: { username: { in: ['usertest_admin','newguy'] } } });
  await prisma.user.deleteMany({ where: { username: { in: ['usertest_admin','newguy'] } } });
  await prisma.role.delete({ where: { id: testRoleId } });
  await prisma.$disconnect();
});

describe('users CRUD', () => {
  it('creates a user', async () => {
    const res = await request(app).post('/api/users')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({
        username: 'newguy', email: 'newguy@x.id', fullName: 'New Guy',
        password: 'secret123', roleId: testRoleId,
      });
    expect(res.status).toBe(201);
    expect(res.body.username).toBe('newguy');
  });

  it('lists users', async () => {
    const res = await request(app).get('/api/users?search=newguy')
      .set('Authorization', `Bearer ${adminToken}`);
    expect(res.status).toBe(200);
    expect(res.body.items.length).toBeGreaterThanOrEqual(1);
  });

  it('rejects duplicate username', async () => {
    const res = await request(app).post('/api/users')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({
        username: 'newguy', email: 'other@x.id', fullName: 'X',
        password: 'secret123', roleId: testRoleId,
      });
    expect(res.status).toBe(409);
  });

  it('resets password returns plaintext', async () => {
    const target = await prisma.user.findUniqueOrThrow({ where: { username: 'newguy' } });
    const res = await request(app).post(`/api/users/${target.id}/reset-password`)
      .set('Authorization', `Bearer ${adminToken}`);
    expect(res.status).toBe(200);
    expect(res.body.password).toHaveLength(16);
  });

  it('prevents self-deactivation', async () => {
    const res = await request(app).delete(`/api/users/${adminId}`)
      .set('Authorization', `Bearer ${adminToken}`);
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('SELF_FORBIDDEN');
  });
});
```

- [ ] **Step 18.6:** Run → PASS

- [ ] **Step 18.7:** Commit

```bash
git add backend/src/modules/user backend/src/__tests__/modules/user.test.ts backend/src/server.ts
git commit -m "feat(user): CRUD + reset-password + unlock with SUPER_ADMIN safeguards"
```

---

## Task 19: modules/role + permission (CRUD + matrix + list)

**Files:**
- Create: `$ROOT/backend/src/modules/role/role.schema.ts`
- Create: `$ROOT/backend/src/modules/role/role.service.ts`
- Create: `$ROOT/backend/src/modules/role/role.controller.ts`
- Create: `$ROOT/backend/src/modules/permission/permission.controller.ts`
- Create: `$ROOT/backend/src/__tests__/modules/role.test.ts`
- Modify: `$ROOT/backend/src/server.ts`

- [ ] **Step 19.1:** Create `role.schema.ts`

```typescript
import { z } from 'zod';

export const CreateRoleSchema = z.object({
  name: z.string().min(2).max(60),
  description: z.string().max(500).optional().nullable(),
});
export const UpdateRoleSchema = CreateRoleSchema.partial();
export const SetPermissionsSchema = z.object({
  permissionIds: z.array(z.string()).min(1, 'minimal 1 permission'),
});
```

- [ ] **Step 19.2:** Create `role.service.ts`

```typescript
import { prisma } from '../../config/db.js';
import { writeAudit } from '../../lib/audit.js';
import { AppError } from '../../lib/errors.js';
import { invalidatePermissionCache } from '../../middleware/auth.js';

export async function listRoles() {
  const rows = await prisma.role.findMany({
    orderBy: { name: 'asc' },
    include: { _count: { select: { users: true, permissions: true } } },
  });
  return rows.map((r) => ({ id: r.id, name: r.name, description: r.description,
    isSystem: r.isSystem, userCount: r._count.users, permissionCount: r._count.permissions,
    createdAt: r.createdAt, updatedAt: r.updatedAt }));
}

export async function getRole(id: string) {
  const r = await prisma.role.findUnique({
    where: { id }, include: { permissions: { include: { permission: true } } },
  });
  if (!r) throw new AppError('NOT_FOUND', 'Role tidak ditemukan', 404);
  return { id: r.id, name: r.name, description: r.description, isSystem: r.isSystem,
    permissions: r.permissions.map((rp) => ({ id: rp.permission.id, module: rp.permission.module,
      action: rp.permission.action, label: rp.permission.label })) };
}

export async function createRole(input: { name: string; description?: string | null }, actor: { id: string; username: string }) {
  try {
    const r = await prisma.role.create({ data: { name: input.name, description: input.description ?? null } });
    await writeAudit({ userId: actor.id, username: actor.username, action: 'ROLE_CREATE',
      module: 'role', entityId: r.id, payload: { name: r.name }, result: 'OK' });
    return r;
  } catch (e: any) {
    if (e.code === 'P2002') throw new AppError('DUPLICATE', 'Nama role sudah dipakai', 409);
    throw e;
  }
}

export async function updateRole(id: string, input: any, actor: { id: string; username: string }) {
  const r = await prisma.role.findUnique({ where: { id } });
  if (!r) throw new AppError('NOT_FOUND', 'Role tidak ditemukan', 404);
  if (r.isSystem) throw new AppError('SYSTEM_ROLE', 'Role sistem tidak dapat diubah', 400);
  const updated = await prisma.role.update({ where: { id }, data: input });
  await writeAudit({ userId: actor.id, username: actor.username, action: 'ROLE_UPDATE',
    module: 'role', entityId: id, payload: { fields: Object.keys(input) }, result: 'OK' });
  return updated;
}

export async function deleteRole(id: string, actor: { id: string; username: string }) {
  const r = await prisma.role.findUnique({ where: { id }, include: { _count: { select: { users: true } } } });
  if (!r) throw new AppError('NOT_FOUND', 'Role tidak ditemukan', 404);
  if (r.isSystem) throw new AppError('SYSTEM_ROLE', 'Role sistem tidak dapat dihapus', 400);
  if (r._count.users > 0) throw new AppError('ROLE_IN_USE', `Masih dipakai ${r._count.users} user`, 400);
  await prisma.role.delete({ where: { id } });
  invalidatePermissionCache(id);
  await writeAudit({ userId: actor.id, username: actor.username, action: 'ROLE_DELETE',
    module: 'role', entityId: id, result: 'OK' });
}

export async function setRolePermissions(id: string, permissionIds: string[], actor: { id: string; username: string }) {
  const r = await prisma.role.findUnique({ where: { id } });
  if (!r) throw new AppError('NOT_FOUND', 'Role tidak ditemukan', 404);
  if (r.isSystem) throw new AppError('SYSTEM_ROLE', 'Role sistem tidak dapat diubah', 400);
  const exists = await prisma.permission.findMany({ where: { id: { in: permissionIds } } });
  if (exists.length !== permissionIds.length) throw new AppError('PERMISSION_INVALID', 'Ada permission yang tidak valid', 400);

  await prisma.$transaction([
    prisma.rolePermission.deleteMany({ where: { roleId: id } }),
    prisma.rolePermission.createMany({ data: permissionIds.map((pid) => ({ roleId: id, permissionId: pid })) }),
  ]);
  invalidatePermissionCache(id);
  await writeAudit({ userId: actor.id, username: actor.username, action: 'ROLE_SET_PERMS',
    module: 'role', entityId: id, payload: { count: permissionIds.length }, result: 'OK' });
}

export async function listAllPermissions() {
  const rows = await prisma.permission.findMany({ orderBy: [{ module: 'asc' }, { action: 'asc' }] });
  return rows;
}
```

- [ ] **Step 19.3:** Create `role.controller.ts`

```typescript
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.js';
import { requirePermission } from '../../middleware/rbac.js';
import { CreateRoleSchema, UpdateRoleSchema, SetPermissionsSchema } from './role.schema.js';
import * as svc from './role.service.js';

export const roleRouter = Router();
roleRouter.use(authenticate);

roleRouter.get('/', requirePermission('role','read'), async (_req, res, next) => {
  try { res.json(await svc.listRoles()); } catch (e) { next(e); }
});
roleRouter.post('/', requirePermission('role','create'), async (req, res, next) => {
  try { const u = req.user!; res.status(201).json(await svc.createRole(CreateRoleSchema.parse(req.body), { id: u.id, username: u.username })); } catch (e) { next(e); }
});
roleRouter.get('/:id', requirePermission('role','read'), async (req, res, next) => {
  try { res.json(await svc.getRole(req.params.id)); } catch (e) { next(e); }
});
roleRouter.patch('/:id', requirePermission('role','update'), async (req, res, next) => {
  try { const u = req.user!; res.json(await svc.updateRole(req.params.id, UpdateRoleSchema.parse(req.body), { id: u.id, username: u.username })); } catch (e) { next(e); }
});
roleRouter.delete('/:id', requirePermission('role','delete'), async (req, res, next) => {
  try { const u = req.user!; await svc.deleteRole(req.params.id, { id: u.id, username: u.username }); res.json({ ok: true }); } catch (e) { next(e); }
});
roleRouter.put('/:id/permissions', requirePermission('role','update'), async (req, res, next) => {
  try { const u = req.user!; const { permissionIds } = SetPermissionsSchema.parse(req.body);
    await svc.setRolePermissions(req.params.id, permissionIds, { id: u.id, username: u.username });
    res.json({ ok: true });
  } catch (e) { next(e); }
});
```

- [ ] **Step 19.4:** Create `permission.controller.ts`

```typescript
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.js';
import { requirePermission } from '../../middleware/rbac.js';
import { listAllPermissions } from '../role/role.service.js';

export const permissionRouter = Router();
permissionRouter.use(authenticate);

permissionRouter.get('/', requirePermission('role','read'), async (_req, res, next) => {
  try { res.json(await listAllPermissions()); } catch (e) { next(e); }
});
```

- [ ] **Step 19.5:** Mount di `src/server.ts`:

```typescript
import { roleRouter } from './modules/role/role.controller.js';
import { permissionRouter } from './modules/permission/permission.controller.js';
// ...
  app.use('/api/roles', roleRouter);
  app.use('/api/permissions', permissionRouter);
```

- [ ] **Step 19.6:** Write integration test `__tests__/modules/role.test.ts`

```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { prisma } from '../../config/db.js';
import { hashPassword } from '../../lib/hash.js';
import { signAccessToken } from '../../lib/jwt.js';
import { createApp } from '../../server.js';

let app: any; let token: string; let createdRoleId: string;

beforeAll(async () => {
  app = await createApp();
  const superRole = await prisma.role.findUniqueOrThrow({ where: { name: 'SUPER_ADMIN' } });
  const u = await prisma.user.create({
    data: { email: 'roletest@x', username: 'roletest_admin', fullName: 'R',
      passwordHash: await hashPassword('x'), roleId: superRole.id },
  });
  token = await signAccessToken({ sub: u.id, roleId: superRole.id });
});

afterAll(async () => {
  await prisma.rolePermission.deleteMany({ where: { roleId: createdRoleId } });
  await prisma.role.deleteMany({ where: { name: { startsWith: 'NEWROLE_' } } });
  await prisma.user.deleteMany({ where: { username: 'roletest_admin' } });
  await prisma.$disconnect();
});

describe('roles', () => {
  it('creates a role', async () => {
    const res = await request(app).post('/api/roles')
      .set('Authorization', `Bearer ${token}`).send({ name: 'NEWROLE_KASIR' });
    expect(res.status).toBe(201);
    createdRoleId = res.body.id;
  });

  it('refuses to update SUPER_ADMIN', async () => {
    const superRole = await prisma.role.findUniqueOrThrow({ where: { name: 'SUPER_ADMIN' } });
    const res = await request(app).patch(`/api/roles/${superRole.id}`)
      .set('Authorization', `Bearer ${token}`).send({ description: 'baru' });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('SYSTEM_ROLE');
  });

  it('sets permissions on role', async () => {
    const perms = await prisma.permission.findMany({ where: { module: 'user' } });
    const res = await request(app).put(`/api/roles/${createdRoleId}/permissions`)
      .set('Authorization', `Bearer ${token}`)
      .send({ permissionIds: perms.map((p) => p.id) });
    expect(res.status).toBe(200);
    const detail = await request(app).get(`/api/roles/${createdRoleId}`).set('Authorization', `Bearer ${token}`);
    expect(detail.body.permissions.length).toBe(perms.length);
  });

  it('lists all permissions', async () => {
    const res = await request(app).get('/api/permissions').set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(200);
    expect(res.body.length).toBeGreaterThanOrEqual(13);
  });
});
```

- [ ] **Step 19.7:** Run → PASS

- [ ] **Step 19.8:** Commit

```bash
git add backend/src/modules/role backend/src/modules/permission backend/src/__tests__/modules/role.test.ts backend/src/server.ts
git commit -m "feat(role): CRUD + permission matrix + list permissions catalog endpoint"
```

---

## Task 20: modules/audit — list with filters

**Files:**
- Create: `$ROOT/backend/src/modules/audit/audit.controller.ts`
- Create: `$ROOT/backend/src/__tests__/modules/audit.test.ts`
- Modify: `$ROOT/backend/src/server.ts`

- [ ] **Step 20.1:** Create `audit.controller.ts`

```typescript
import { Router } from 'express';
import { z } from 'zod';
import { authenticate } from '../../middleware/auth.js';
import { requirePermission } from '../../middleware/rbac.js';
import { prisma } from '../../config/db.js';

export const auditRouter = Router();
auditRouter.use(authenticate);

const Q = z.object({
  module: z.string().optional(),
  userId: z.string().optional(),
  action: z.string().optional(),
  result: z.enum(['OK','FAIL','DENIED']).optional(),
  from: z.coerce.date().optional(),
  to: z.coerce.date().optional(),
  page: z.coerce.number().min(1).default(1),
  pageSize: z.coerce.number().min(1).max(100).default(50),
});

auditRouter.get('/', requirePermission('audit','read'), async (req, res, next) => {
  try {
    const q = Q.parse(req.query);
    const where: any = {};
    if (q.module) where.module = q.module;
    if (q.userId) where.userId = q.userId;
    if (q.action) where.action = q.action;
    if (q.result) where.result = q.result;
    if (q.from || q.to) where.at = {};
    if (q.from) where.at.gte = q.from;
    if (q.to) where.at.lte = q.to;

    const [total, rows] = await Promise.all([
      prisma.auditLog.count({ where }),
      prisma.auditLog.findMany({
        where, orderBy: { id: 'desc' },
        skip: (q.page - 1) * q.pageSize, take: q.pageSize,
      }),
    ]);
    // serialize BigInt id ke string
    res.json({
      total, page: q.page, pageSize: q.pageSize,
      items: rows.map((r) => ({ ...r, id: r.id.toString() })),
    });
  } catch (e) { next(e); }
});
```

- [ ] **Step 20.2:** Mount di `src/server.ts`:

```typescript
import { auditRouter } from './modules/audit/audit.controller.js';
// ...
  app.use('/api/audit-log', auditRouter);
```

- [ ] **Step 20.3:** Integration test

```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { prisma } from '../../config/db.js';
import { hashPassword } from '../../lib/hash.js';
import { signAccessToken } from '../../lib/jwt.js';
import { writeAudit } from '../../lib/audit.js';
import { createApp } from '../../server.js';

let app: any; let token: string;
beforeAll(async () => {
  app = await createApp();
  const role = await prisma.role.findUniqueOrThrow({ where: { name: 'SUPER_ADMIN' } });
  const u = await prisma.user.create({ data: { email: 'audit@x', username: 'audit_admin', fullName: 'A',
    passwordHash: await hashPassword('x'), roleId: role.id } });
  token = await signAccessToken({ sub: u.id, roleId: role.id });
  await writeAudit({ userId: u.id, username: 'audit_admin', action: 'TEST_AUD', module: 'auditmod', result: 'OK' });
});
afterAll(async () => {
  await prisma.auditLog.deleteMany({ where: { username: 'audit_admin' } });
  await prisma.user.deleteMany({ where: { username: 'audit_admin' } });
  await prisma.$disconnect();
});

describe('audit list', () => {
  it('returns rows with filter module', async () => {
    const res = await request(app).get('/api/audit-log?module=auditmod')
      .set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(200);
    expect(res.body.items.length).toBeGreaterThanOrEqual(1);
    expect(typeof res.body.items[0].id).toBe('string');
  });
});
```

- [ ] **Step 20.4:** Run → PASS

- [ ] **Step 20.5:** Commit

```bash
git add backend/src/modules/audit backend/src/__tests__/modules/audit.test.ts backend/src/server.ts
git commit -m "feat(audit): paginated list endpoint with filters and BigInt serialization"
```

---

## Task 21: modules/files — upload, download, delete

**Files:**
- Create: `$ROOT/backend/src/modules/files/files.controller.ts`
- Create: `$ROOT/backend/src/__tests__/modules/files.test.ts`
- Modify: `$ROOT/backend/src/server.ts`

- [ ] **Step 21.1:** Create `files.controller.ts`

```typescript
import { Router } from 'express';
import multer from 'multer';
import { createReadStream } from 'fs';
import { z } from 'zod';
import { authenticate } from '../../middleware/auth.js';
import { requirePermission } from '../../middleware/rbac.js';
import { storeFile, getFileStreamPath, softDeleteFile } from '../../lib/files.js';
import { prisma } from '../../config/db.js';
import { AppError } from '../../lib/errors.js';

export const filesRouter = Router();

const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });

const UploadMetaSchema = z.object({
  ownerModule: z.string().min(1).max(40),
  ownerId: z.string().optional().nullable(),
  isPublic: z.enum(['true','false']).optional(),
});

filesRouter.post('/', authenticate, requirePermission('files','create'), upload.single('file'),
  async (req, res, next) => {
    try {
      if (!req.file) throw new AppError('FILE_MISSING', 'Field "file" wajib', 400);
      const meta = UploadMetaSchema.parse(req.body);
      const f = await storeFile({
        buffer: req.file.buffer,
        originalName: req.file.originalname,
        mimeType: req.file.mimetype,
        ownerModule: meta.ownerModule,
        ownerId: meta.ownerId ?? null,
        isPublic: meta.isPublic === 'true',
        uploadedBy: req.user!.id,
      });
      res.status(201).json({
        id: f.id, originalName: f.originalName, mimeType: f.mimeType,
        sizeBytes: f.sizeBytes.toString(), sha256: f.sha256,
        downloadUrl: `/api/files/${f.id}`,
      });
    } catch (e) { next(e); }
  });

filesRouter.get('/:id', async (req, res, next) => {
  try {
    const meta = await prisma.fileObject.findUnique({ where: { id: req.params.id } });
    if (!meta || meta.deletedAt) throw new AppError('NOT_FOUND', 'File tidak ditemukan', 404);
    if (!meta.isPublic) {
      // butuh authenticate + permission
      // panggil manual middleware chain:
      await new Promise<void>((resolve, reject) => authenticate(req, res, (err) => err ? reject(err) : resolve()));
      await new Promise<void>((resolve, reject) =>
        requirePermission('files','read')(req, res, (err) => err ? reject(err) : resolve()));
    }
    const path = await getFileStreamPath(req.params.id);
    if (!path) throw new AppError('NOT_FOUND', 'File hilang dari storage', 404);
    res.setHeader('Content-Type', meta.mimeType);
    res.setHeader('Content-Length', meta.sizeBytes.toString());
    res.setHeader('ETag', `"${meta.sha256}"`);
    res.setHeader('Cache-Control', meta.isPublic ? 'public, max-age=3600' : 'private, max-age=300');
    if (req.get('if-none-match') === `"${meta.sha256}"`) return res.status(304).end();
    createReadStream(path).pipe(res);
  } catch (e) { next(e); }
});

filesRouter.delete('/:id', authenticate, requirePermission('files','delete'), async (req, res, next) => {
  try {
    const meta = await prisma.fileObject.findUnique({ where: { id: req.params.id } });
    if (!meta || meta.deletedAt) throw new AppError('NOT_FOUND', 'File tidak ditemukan', 404);
    await softDeleteFile(req.params.id);
    res.json({ ok: true });
  } catch (e) { next(e); }
});
```

- [ ] **Step 21.2:** Mount di `src/server.ts`:

```typescript
import { filesRouter } from './modules/files/files.controller.js';
// ...
  app.use('/api/files', filesRouter);
```

- [ ] **Step 21.3:** Integration test

```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { prisma } from '../../config/db.js';
import { hashPassword } from '../../lib/hash.js';
import { signAccessToken } from '../../lib/jwt.js';

const tmp = mkdtempSync(join(tmpdir(), 'kopdes-int-'));
process.env.KOPDES_DIR = tmp;

import { createApp } from '../../server.js';
let app: any; let token: string;

beforeAll(async () => {
  app = await createApp();
  const role = await prisma.role.findUniqueOrThrow({ where: { name: 'SUPER_ADMIN' } });
  const u = await prisma.user.create({ data: { email: 'files@x', username: 'files_admin', fullName: 'F',
    passwordHash: await hashPassword('x'), roleId: role.id } });
  token = await signAccessToken({ sub: u.id, roleId: role.id });
});

afterAll(async () => {
  await prisma.fileObject.deleteMany({ where: { ownerModule: 'inttest' } });
  await prisma.user.deleteMany({ where: { username: 'files_admin' } });
  rmSync(tmp, { recursive: true, force: true });
  await prisma.$disconnect();
});

describe('files', () => {
  it('uploads, downloads, deletes', async () => {
    const up = await request(app).post('/api/files')
      .set('Authorization', `Bearer ${token}`)
      .field('ownerModule', 'inttest').field('ownerId', 'O1').field('isPublic', 'false')
      .attach('file', Buffer.from('hello int test'), 'a.txt');
    expect(up.status).toBe(201);
    const id = up.body.id;

    const dn = await request(app).get(`/api/files/${id}`).set('Authorization', `Bearer ${token}`);
    expect(dn.status).toBe(200);
    expect(dn.text).toBe('hello int test');
    expect(dn.headers.etag).toMatch(/"[0-9a-f]{64}"/);

    const dnNoAuth = await request(app).get(`/api/files/${id}`);
    expect(dnNoAuth.status).toBe(401);

    const del = await request(app).delete(`/api/files/${id}`).set('Authorization', `Bearer ${token}`);
    expect(del.status).toBe(200);
    const dn2 = await request(app).get(`/api/files/${id}`).set('Authorization', `Bearer ${token}`);
    expect(dn2.status).toBe(404);
  });
});
```

- [ ] **Step 21.4:** Run → PASS

- [ ] **Step 21.5:** Commit

```bash
git add backend/src/modules/files backend/src/__tests__/modules/files.test.ts backend/src/server.ts
git commit -m "feat(files): upload/download(stream+ETag)/soft-delete with isPublic gating"
```

---

## Task 22: modules/jobs — status & list

**Files:**
- Create: `$ROOT/backend/src/modules/jobs/jobs.controller.ts`
- Modify: `$ROOT/backend/src/server.ts`

- [ ] **Step 22.1:** Create `jobs.controller.ts`

```typescript
import { Router } from 'express';
import { z } from 'zod';
import { authenticate } from '../../middleware/auth.js';
import { requirePermission } from '../../middleware/rbac.js';
import { prisma } from '../../config/db.js';
import { AppError } from '../../lib/errors.js';

export const jobsRouter = Router();
jobsRouter.use(authenticate);

function serialize(j: any) { return { ...j, id: j.id.toString() }; }

jobsRouter.get('/:id', requirePermission('jobs','read'), async (req, res, next) => {
  try {
    let id: bigint;
    try { id = BigInt(req.params.id); } catch { throw new AppError('INVALID_ID', 'Invalid job id', 400); }
    const j = await prisma.job.findUnique({ where: { id } });
    if (!j) throw new AppError('NOT_FOUND', 'Job tidak ditemukan', 404);
    res.json(serialize(j));
  } catch (e) { next(e); }
});

const Q = z.object({
  queue: z.string().optional(),
  status: z.enum(['PENDING','RUNNING','DONE','FAILED','DEAD']).optional(),
  page: z.coerce.number().min(1).default(1),
  pageSize: z.coerce.number().min(1).max(100).default(50),
});

jobsRouter.get('/', requirePermission('jobs','read'), async (req, res, next) => {
  try {
    const q = Q.parse(req.query);
    const where: any = {};
    if (q.queue) where.queue = q.queue;
    if (q.status) where.status = q.status;
    const [total, rows] = await Promise.all([
      prisma.job.count({ where }),
      prisma.job.findMany({ where, orderBy: { id: 'desc' }, skip: (q.page - 1) * q.pageSize, take: q.pageSize }),
    ]);
    res.json({ total, page: q.page, pageSize: q.pageSize, items: rows.map(serialize) });
  } catch (e) { next(e); }
});
```

- [ ] **Step 22.2:** Mount di `src/server.ts`:

```typescript
import { jobsRouter } from './modules/jobs/jobs.controller.js';
// ...
  app.use('/api/jobs', jobsRouter);
```

- [ ] **Step 22.3:** Smoke test manual

```bash
# di shell: jalankan dev server, login, enqueue job via psql:
psql "$DATABASE_URL" -c "INSERT INTO jobs (queue, payload) VALUES ('test', '{}'::jsonb) RETURNING id;"
# GET /api/jobs/<id> dengan token → should return PENDING
```

- [ ] **Step 22.4:** Commit

```bash
git add backend/src/modules/jobs backend/src/server.ts
git commit -m "feat(jobs): GET /jobs/:id and GET /jobs with filters"
```

---

## Task 23: Worker (jobs runner + housekeeping)

**Files:**
- Create: `$ROOT/backend/src/workers/jobs-runner.ts`
- Create: `$ROOT/backend/src/workers/housekeeping.ts`
- Create: `$ROOT/backend/src/workers/index.ts`
- Create: `$ROOT/backend/src/__tests__/workers/runner.test.ts`

- [ ] **Step 23.1:** Create `workers/jobs-runner.ts`

```typescript
import { claimNextJob, completeJob, failJob, resetStuckJobs } from '../lib/queue.js';
import { prisma } from '../config/db.js';

export type JobHandler = (payload: any) => Promise<string | void>;

const handlers: Record<string, JobHandler> = {
  // contoh handler smoke (dipakai untuk verifikasi)
  smoke: async (payload: { sleep?: number }) => {
    if (payload?.sleep) await new Promise((r) => setTimeout(r, payload.sleep));
    return `smoke-ok-${Date.now()}`;
  },
};

export function registerHandler(queue: string, fn: JobHandler): void {
  handlers[queue] = fn;
}

export async function runOneCycle(workerId: string): Promise<boolean> {
  // Pilih queue secara round-robin sederhana: ambil daftar queue distinct dari DB
  const queues = (await prisma.$queryRaw<{ queue: string }[]>`
    SELECT DISTINCT queue FROM jobs WHERE status='PENDING' AND "runAt" <= NOW()
  `).map((r) => r.queue);

  for (const q of queues) {
    const job = await claimNextJob(q, workerId);
    if (!job) continue;
    const handler = handlers[q];
    if (!handler) {
      await failJob(job.id, `No handler for queue '${q}'`);
      continue;
    }
    try {
      const resultRef = await handler(job.payload);
      await completeJob(job.id, resultRef || undefined);
    } catch (e: any) {
      await failJob(job.id, e?.message || String(e));
    }
    return true;
  }
  return false;
}

export async function startWorkerLoop(workerId: string, opts: { idleMs?: number; stopSignal?: AbortSignal } = {}): Promise<void> {
  const idle = opts.idleMs ?? 500;
  // Reset stuck jobs sekali saat startup
  await resetStuckJobs();
  while (!opts.stopSignal?.aborted) {
    const did = await runOneCycle(workerId);
    if (!did) await new Promise((r) => setTimeout(r, idle));
  }
}
```

- [ ] **Step 23.2:** Create `workers/housekeeping.ts`

```typescript
import { prisma } from '../config/db.js';

export async function cleanExpiredIdempotency(): Promise<number> {
  const res = await prisma.idempotencyKey.deleteMany({ where: { expiresAt: { lt: new Date() } } });
  return res.count;
}

export async function cleanOldDoneJobs(): Promise<number> {
  const cutoff = new Date(Date.now() - 7 * 86400_000);
  const res = await prisma.job.deleteMany({
    where: { status: { in: ['DONE','DEAD'] }, finishedAt: { lt: cutoff } },
  });
  return res.count;
}

export async function cleanOldRefreshTokens(): Promise<number> {
  const cutoff = new Date(Date.now() - 7 * 86400_000);
  const res = await prisma.refreshToken.deleteMany({
    where: { OR: [{ expiresAt: { lt: new Date() } }, { revokedAt: { lt: cutoff } }] },
  });
  return res.count;
}

export async function startHousekeeping(intervalMs = 60 * 60 * 1000): Promise<void> {
  const tick = async () => {
    try {
      const i = await cleanExpiredIdempotency();
      const j = await cleanOldDoneJobs();
      const r = await cleanOldRefreshTokens();
      console.log(`[housekeeping] cleaned: idempotency=${i} jobs=${j} refreshTokens=${r}`);
    } catch (e) { console.error('[housekeeping] error', e); }
  };
  await tick();
  setInterval(tick, intervalMs);
}
```

- [ ] **Step 23.3:** Create `workers/index.ts`

```typescript
import { startWorkerLoop } from './jobs-runner.js';
import { startHousekeeping } from './housekeeping.js';

const workerId = `w-${process.pid}-${Math.random().toString(36).slice(2,8)}`;

async function main(): Promise<void> {
  console.log(`[worker] starting ${workerId}`);
  startHousekeeping();
  await startWorkerLoop(workerId);
}

main().catch((e) => {
  console.error('[worker] fatal', e);
  process.exit(1);
});
```

- [ ] **Step 23.4:** Write test `__tests__/workers/runner.test.ts`

```typescript
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { prisma } from '../../config/db.js';
import { enqueueJob } from '../../lib/queue.js';
import { runOneCycle, registerHandler } from '../../workers/jobs-runner.js';

describe('jobs-runner', () => {
  beforeEach(async () => { await prisma.job.deleteMany({ where: { queue: 'unittest_q' } }); });
  afterAll(async () => prisma.$disconnect());

  it('processes a registered job', async () => {
    registerHandler('unittest_q', async (p: any) => `done:${p.x}`);
    const j = await enqueueJob('unittest_q', { x: 42 });
    const did = await runOneCycle('w-test');
    expect(did).toBe(true);
    const fresh = await prisma.job.findUnique({ where: { id: j.id } });
    expect(fresh?.status).toBe('DONE');
    expect(fresh?.resultRef).toBe('done:42');
  });

  it('fails job to DEAD without handler', async () => {
    const j = await enqueueJob('no_handler_q', {}, { maxAttempts: 1 });
    await runOneCycle('w-test');
    const fresh = await prisma.job.findUnique({ where: { id: j.id } });
    expect(fresh?.status).toBe('DEAD');
    expect(fresh?.lastError).toContain('No handler');
  });
});
```

- [ ] **Step 23.5:** Run → PASS

- [ ] **Step 23.6:** Smoke E2E worker: build, jalankan worker, enqueue smoke job, verifikasi DONE

```bash
cd $ROOT/backend && npm run build
node dist/workers/index.js &
WORKER_PID=$!
sleep 1
psql "$DATABASE_URL" -c "INSERT INTO jobs (queue, payload) VALUES ('smoke', '{\"sleep\":100}'::jsonb) RETURNING id;"
sleep 2
psql "$DATABASE_URL" -c "SELECT id,status,resultRef,attempts FROM jobs WHERE queue='smoke' ORDER BY id DESC LIMIT 1;"
kill $WORKER_PID
```
Expected: status `DONE`, resultRef `smoke-ok-...`.

- [ ] **Step 23.7:** Commit

```bash
git add backend/src/workers backend/src/__tests__/workers/runner.test.ts
git commit -m "feat(worker): jobs runner with handler registry + housekeeping for expired records"
```

---

## Task 24: Admin script — reset-admin-password

**Files:**
- Create: `$ROOT/backend/src/scripts/reset-admin-password.ts`

- [ ] **Step 24.1:** Create script

```typescript
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';

const prisma = new PrismaClient();

function parseArgs(): { username: string } {
  const args = process.argv.slice(2);
  const idx = args.findIndex((a) => a === '--username' || a.startsWith('--username='));
  if (idx < 0) throw new Error('Usage: npm run admin:reset-password -- --username=<username>');
  const arg = args[idx];
  return { username: arg.startsWith('--username=') ? arg.slice('--username='.length) : args[idx + 1] };
}

function generatePassword(): string {
  const charset = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#%*';
  const bytes = randomBytes(16);
  return Array.from(bytes, (b) => charset[b % charset.length]).join('');
}

async function main(): Promise<void> {
  const { username } = parseArgs();
  const user = await prisma.user.findUnique({ where: { username }, include: { role: true } });
  if (!user) throw new Error(`User '${username}' tidak ditemukan`);
  if (user.role.name !== 'SUPER_ADMIN') {
    console.warn(`[warn] '${username}' bukan SUPER_ADMIN. Lanjut? Ctrl+C untuk batal dalam 3 detik...`);
    await new Promise((r) => setTimeout(r, 3000));
  }
  const password = generatePassword();
  const passwordHash = await bcrypt.hash(password, 12);
  await prisma.user.update({
    where: { id: user.id },
    data: { passwordHash, mustChangePass: true, failedAttempts: 0, lockedUntil: null },
  });
  await prisma.refreshToken.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } });

  console.log('');
  console.log('================================================================');
  console.log(`  PASSWORD '${username}' DI-RESET — CATAT SEKARANG`);
  console.log(`  password : ${password}`);
  console.log('  → Login pertama akan dipaksa ganti password.');
  console.log('================================================================');
}

main()
  .catch((e) => { console.error('[reset-admin-password] FAILED', e); process.exit(1); })
  .finally(() => prisma.$disconnect());
```

- [ ] **Step 24.2:** Manual test

```bash
cd $ROOT/backend && npm run admin:reset-password -- --username=superadmin
```
Expected: cetak password baru. Login dengan password lama → harus gagal; login dengan password baru → harus diminta ganti.

- [ ] **Step 24.3:** Commit

```bash
git add backend/src/scripts/reset-admin-password.ts
git commit -m "feat(scripts): admin password reset script with rotation safety"
```

---

## Task 25: Frontend — API client + auth context + usePermission

**Files:**
- Create: `$ROOT/frontend/src/lib/api.js`
- Create: `$ROOT/frontend/src/lib/auth-context.js`
- Create: `$ROOT/frontend/src/lib/use-permission.js`
- Create: `$ROOT/frontend/src/lib/idempotency.js`

- [ ] **Step 25.1:** Create `lib/api.js`

```javascript
'use client';

let accessToken = null;
const listeners = new Set();
let refreshPromise = null;

export function setAccessToken(t) {
  accessToken = t;
  listeners.forEach((fn) => fn(t));
}
export function getAccessToken() { return accessToken; }
export function onTokenChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }

async function refreshOnce() {
  if (!refreshPromise) {
    refreshPromise = (async () => {
      const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
      if (!res.ok) { setAccessToken(null); throw new Error('refresh_failed'); }
      const data = await res.json();
      setAccessToken(data.accessToken);
      return data.accessToken;
    })().finally(() => { refreshPromise = null; });
  }
  return refreshPromise;
}

export async function api(path, { method = 'GET', body, headers = {}, idempotencyKey } = {}) {
  const doFetch = async (token) => {
    const h = { 'Accept': 'application/json', ...headers };
    if (token) h['Authorization'] = `Bearer ${token}`;
    if (body && !(body instanceof FormData)) {
      h['Content-Type'] = 'application/json';
    }
    if (idempotencyKey) h['Idempotency-Key'] = idempotencyKey;
    return fetch(`/api${path}`, {
      method, headers: h, credentials: 'include',
      body: body instanceof FormData ? body : (body ? JSON.stringify(body) : undefined),
    });
  };

  let res = await doFetch(accessToken);
  if (res.status === 401 && accessToken) {
    try { const fresh = await refreshOnce(); res = await doFetch(fresh); }
    catch { /* fallthrough — biarkan 401 */ }
  }
  const isJson = res.headers.get('content-type')?.includes('application/json');
  const data = isJson ? await res.json() : await res.text();
  if (!res.ok) {
    const err = new Error(data?.message || res.statusText);
    err.code = data?.error;
    err.status = res.status;
    err.details = data?.details;
    throw err;
  }
  return data;
}
```

- [ ] **Step 25.2:** Create `lib/idempotency.js`

```javascript
export function uuidv4() {
  // RFC4122 v4 dengan crypto.getRandomValues (browser & Node 19+)
  const b = crypto.getRandomValues(new Uint8Array(16));
  b[6] = (b[6] & 0x0f) | 0x40;
  b[8] = (b[8] & 0x3f) | 0x80;
  const h = Array.from(b, (x) => x.toString(16).padStart(2, '0'));
  return `${h.slice(0,4).join('')}-${h.slice(4,6).join('')}-${h.slice(6,8).join('')}-${h.slice(8,10).join('')}-${h.slice(10,16).join('')}`;
}
```

- [ ] **Step 25.3:** Create `lib/auth-context.js`

```javascript
'use client';
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { api, setAccessToken, getAccessToken } from './api';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  const loadMe = useCallback(async () => {
    try {
      const me = await api('/auth/me');
      setUser(me);
    } catch { setUser(null); }
    finally { setLoading(false); }
  }, []);

  useEffect(() => {
    // coba refresh saat mount supaya cookie refresh_token aktif → dapat accessToken baru
    (async () => {
      try {
        const r = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
        if (r.ok) { const d = await r.json(); setAccessToken(d.accessToken); }
      } catch {}
      await loadMe();
    })();
  }, [loadMe]);

  const login = async (usernameOrEmail, password) => {
    const res = await api('/auth/login', { method: 'POST', body: { usernameOrEmail, password } });
    setAccessToken(res.accessToken);
    setUser({ ...res.user, permissions: [], mustChangePass: res.mustChangePass });
    await loadMe();
    return res;
  };

  const logout = async () => {
    try { await api('/auth/logout', { method: 'POST' }); } catch {}
    setAccessToken(null);
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, loading, login, logout, reload: loadMe }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
}
```

- [ ] **Step 25.4:** Create `lib/use-permission.js`

```javascript
'use client';
import { useAuth } from './auth-context';

export function usePermission() {
  const { user } = useAuth();
  const isSuper = user?.role?.name === 'SUPER_ADMIN';
  const set = new Set(user?.permissions ?? []);
  return {
    can: (module, action) => isSuper || set.has(`${module}:${action}`),
    isSuper,
  };
}
```

- [ ] **Step 25.5:** Update `app/layout.js` untuk wrap dengan `AuthProvider`

```javascript
import './globals.css';
import { AuthProvider } from '@/lib/auth-context';

export const metadata = {
  title: 'Koperasi Desa Merah Putih',
  description: 'Sistem informasi koperasi desa',
};

export default function RootLayout({ children }) {
  return (
    <html lang="id">
      <body className="min-h-screen bg-gray-50 text-gray-900 antialiased">
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  );
}
```

- [ ] **Step 25.6:** Commit

```bash
git add frontend/src/lib frontend/src/app/layout.js
git commit -m "feat(frontend): api client with auto-refresh, AuthProvider, usePermission hook"
```

---

## Task 26: Frontend — Login page

**Files:**
- Create: `$ROOT/frontend/src/app/login/page.js`

- [ ] **Step 26.1:** Create login page

```javascript
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';

export default function LoginPage() {
  const { login } = useAuth();
  const router = useRouter();
  const [form, setForm] = useState({ usernameOrEmail: '', password: '' });
  const [err, setErr] = useState('');
  const [busy, setBusy] = useState(false);

  const onSubmit = async (e) => {
    e.preventDefault();
    setErr(''); setBusy(true);
    try {
      const res = await login(form.usernameOrEmail, form.password);
      router.push(res.mustChangePass ? '/profile?force=1' : '/dashboard');
    } catch (e) {
      setErr(e.message || 'Login gagal');
    } finally { setBusy(false); }
  };

  return (
    <main className="flex min-h-screen items-center justify-center px-4">
      <form onSubmit={onSubmit} className="w-full max-w-sm rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
        <h1 className="text-xl font-semibold">Masuk</h1>
        <p className="mb-4 text-sm text-gray-500">Koperasi Desa Merah Putih</p>
        <label className="mb-3 block">
          <span className="block text-sm font-medium">Username atau Email</span>
          <input
            className="mt-1 w-full rounded border border-gray-300 px-3 py-2"
            value={form.usernameOrEmail}
            onChange={(e) => setForm({ ...form, usernameOrEmail: e.target.value })}
            required
          />
        </label>
        <label className="mb-4 block">
          <span className="block text-sm font-medium">Password</span>
          <input
            type="password"
            className="mt-1 w-full rounded border border-gray-300 px-3 py-2"
            value={form.password}
            onChange={(e) => setForm({ ...form, password: e.target.value })}
            required
          />
        </label>
        {err && <p className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-700">{err}</p>}
        <button disabled={busy} className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50">
          {busy ? 'Memproses...' : 'Masuk'}
        </button>
        <p className="mt-4 text-xs text-gray-500">
          Lupa password? Hubungi admin koperasi.
        </p>
      </form>
    </main>
  );
}
```

- [ ] **Step 26.2:** Manual smoke (run dev backend + frontend, login dengan superadmin)

```bash
# shell A
cd $ROOT/backend && npm run dev
# shell B
cd $ROOT/frontend && npm run dev
# browser http://localhost:3100/login → login dengan superadmin + password dari seed
# → harus redirect ke /profile?force=1
```

- [ ] **Step 26.3:** Commit

```bash
git add frontend/src/app/login
git commit -m "feat(frontend): login page with form, validation, redirect-on-force-change"
```

---

## Task 27: Frontend — AppShell (sidebar + topbar)

**Files:**
- Create: `$ROOT/frontend/src/components/shells/AppShell.js`
- Create: `$ROOT/frontend/src/components/shells/SidebarLink.js`

- [ ] **Step 27.1:** Create `SidebarLink.js`

```javascript
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

export default function SidebarLink({ href, children }) {
  const path = usePathname();
  const active = path === href || path.startsWith(href + '/');
  return (
    <Link href={href}
      className={`block rounded px-3 py-2 text-sm ${active ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-700 hover:bg-gray-100'}`}>
      {children}
    </Link>
  );
}
```

- [ ] **Step 27.2:** Create `AppShell.js`

```javascript
'use client';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/lib/auth-context';
import { usePermission } from '@/lib/use-permission';
import SidebarLink from './SidebarLink';

export default function AppShell({ children }) {
  const { user, loading, logout } = useAuth();
  const { can } = usePermission();
  const router = useRouter();

  if (loading) return <main className="flex min-h-screen items-center justify-center text-gray-500">Memuat...</main>;
  if (!user) {
    if (typeof window !== 'undefined') router.replace('/login');
    return null;
  }

  return (
    <div className="flex min-h-screen">
      <aside className="w-60 border-r border-gray-200 bg-white p-3">
        <div className="mb-4 px-3 py-2">
          <Link href="/dashboard" className="text-sm font-semibold text-blue-700">Koperasi DMP</Link>
        </div>
        <nav className="space-y-1">
          <SidebarLink href="/dashboard">Dashboard</SidebarLink>
          {can('user','read') && <SidebarLink href="/admin/users">User</SidebarLink>}
          {can('role','read') && <SidebarLink href="/admin/roles">Role &amp; Permission</SidebarLink>}
          {can('audit','read') && <SidebarLink href="/admin/audit-log">Audit Log</SidebarLink>}
          <SidebarLink href="/profile">Profil Saya</SidebarLink>
        </nav>
      </aside>
      <div className="flex flex-1 flex-col">
        <header className="flex h-12 items-center justify-between border-b border-gray-200 bg-white px-6">
          <div className="text-sm text-gray-600">{user.fullName} <span className="text-gray-400">· {user.role.name}</span></div>
          <button onClick={async () => { await logout(); router.replace('/login'); }} className="text-sm text-gray-600 hover:text-red-600">
            Keluar
          </button>
        </header>
        <main className="flex-1 p-6">{children}</main>
      </div>
    </div>
  );
}
```

- [ ] **Step 27.3:** Commit

```bash
git add frontend/src/components/shells
git commit -m "feat(frontend): AppShell with permission-aware sidebar and topbar"
```

---

## Task 28: Frontend — Dashboard placeholder

**Files:**
- Create: `$ROOT/frontend/src/app/dashboard/layout.js`
- Create: `$ROOT/frontend/src/app/dashboard/page.js`

- [ ] **Step 28.1:** Create layout

```javascript
import AppShell from '@/components/shells/AppShell';
export default function Layout({ children }) { return <AppShell>{children}</AppShell>; }
```

- [ ] **Step 28.2:** Create page

```javascript
export default function Dashboard() {
  return (
    <div>
      <h1 className="text-2xl font-semibold">Dashboard</h1>
      <p className="mt-2 text-gray-600">
        Selamat datang. Widget statistik dan grafik akan ditambahkan di Fase 5.
      </p>
    </div>
  );
}
```

- [ ] **Step 28.3:** Commit

```bash
git add frontend/src/app/dashboard
git commit -m "feat(frontend): dashboard placeholder page wrapped in AppShell"
```

---

## Task 29: Frontend — admin/users (list, create, edit, reset, unlock)

**Files:**
- Create: `$ROOT/frontend/src/app/admin/layout.js`
- Create: `$ROOT/frontend/src/app/admin/users/page.js`
- Create: `$ROOT/frontend/src/app/admin/users/new/page.js`
- Create: `$ROOT/frontend/src/app/admin/users/[id]/page.js`

- [ ] **Step 29.1:** Create admin layout (reuse AppShell)

```javascript
import AppShell from '@/components/shells/AppShell';
export default function Layout({ children }) { return <AppShell>{children}</AppShell>; }
```

- [ ] **Step 29.2:** Create `admin/users/page.js` (list)

```javascript
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { api } from '@/lib/api';

export default function UsersList() {
  const [data, setData] = useState({ items: [], total: 0, page: 1, pageSize: 20 });
  const [search, setSearch] = useState('');
  const [busy, setBusy] = useState(false);

  const load = async (page = 1) => {
    setBusy(true);
    try {
      const q = new URLSearchParams({ page, pageSize: 20 });
      if (search) q.set('search', search);
      setData(await api(`/users?${q.toString()}`));
    } finally { setBusy(false); }
  };
  useEffect(() => { load(1); }, []);

  const reset = async (id) => {
    if (!confirm('Reset password user ini?')) return;
    const { password } = await api(`/users/${id}/reset-password`, { method: 'POST' });
    alert(`Password baru: ${password}\nCatat sekarang. User wajib ganti saat login berikutnya.`);
  };
  const unlock = async (id) => { await api(`/users/${id}/unlock`, { method: 'POST' }); load(data.page); };
  const deact = async (id) => {
    if (!confirm('Nonaktifkan user ini?')) return;
    await api(`/users/${id}`, { method: 'DELETE' }); load(data.page);
  };

  return (
    <div>
      <div className="mb-4 flex items-center justify-between">
        <h1 className="text-2xl font-semibold">User</h1>
        <Link href="/admin/users/new" className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white">+ Tambah</Link>
      </div>
      <div className="mb-3 flex gap-2">
        <input className="rounded border border-gray-300 px-3 py-1.5 text-sm" placeholder="Cari username/email/nama"
          value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && load(1)} />
        <button onClick={() => load(1)} className="rounded border border-gray-300 px-3 py-1.5 text-sm">Cari</button>
      </div>
      <table className="w-full border-collapse border border-gray-200 bg-white text-sm">
        <thead>
          <tr className="bg-gray-50 text-left">
            <th className="border border-gray-200 px-3 py-2">Username</th>
            <th className="border border-gray-200 px-3 py-2">Nama</th>
            <th className="border border-gray-200 px-3 py-2">Role</th>
            <th className="border border-gray-200 px-3 py-2">Status</th>
            <th className="border border-gray-200 px-3 py-2">Aksi</th>
          </tr>
        </thead>
        <tbody>
          {data.items.map((u) => (
            <tr key={u.id}>
              <td className="border border-gray-200 px-3 py-2">{u.username}</td>
              <td className="border border-gray-200 px-3 py-2">{u.fullName}</td>
              <td className="border border-gray-200 px-3 py-2">{u.role?.name}</td>
              <td className="border border-gray-200 px-3 py-2">
                {u.isActive ? <span className="text-green-700">Aktif</span> : <span className="text-gray-500">Nonaktif</span>}
                {u.lockedUntil && new Date(u.lockedUntil) > new Date() && <span className="ml-2 text-red-700">Terkunci</span>}
              </td>
              <td className="border border-gray-200 px-3 py-2 space-x-2 text-sm">
                <Link href={`/admin/users/${u.id}`} className="text-blue-700 hover:underline">Edit</Link>
                <button onClick={() => reset(u.id)} className="text-amber-700 hover:underline">Reset</button>
                {u.lockedUntil && <button onClick={() => unlock(u.id)} className="text-green-700 hover:underline">Unlock</button>}
                {u.isActive && <button onClick={() => deact(u.id)} className="text-red-700 hover:underline">Nonaktifkan</button>}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="mt-2 text-xs text-gray-500">Total: {data.total} · Hal {data.page}</p>
      {busy && <p className="mt-2 text-xs text-gray-400">Memuat...</p>}
    </div>
  );
}
```

- [ ] **Step 29.3:** Create `admin/users/new/page.js`

```javascript
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';

export default function NewUser() {
  const router = useRouter();
  const [roles, setRoles] = useState([]);
  const [form, setForm] = useState({ username: '', email: '', fullName: '', phone: '', password: '', roleId: '' });
  const [err, setErr] = useState('');
  const [busy, setBusy] = useState(false);

  useEffect(() => { api('/roles').then(setRoles); }, []);

  const submit = async (e) => {
    e.preventDefault(); setErr(''); setBusy(true);
    try {
      await api('/users', { method: 'POST', body: form });
      router.push('/admin/users');
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };

  return (
    <div className="max-w-md">
      <h1 className="mb-4 text-2xl font-semibold">User Baru</h1>
      <form onSubmit={submit} className="space-y-3 rounded border border-gray-200 bg-white p-4">
        {['username','email','fullName','phone','password'].map((f) => (
          <label key={f} className="block">
            <span className="block text-sm font-medium capitalize">{f}</span>
            <input type={f === 'password' ? 'password' : 'text'} required={f !== 'phone'}
              className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
              value={form[f]} onChange={(e) => setForm({ ...form, [f]: e.target.value })} />
          </label>
        ))}
        <label className="block">
          <span className="block text-sm font-medium">Role</span>
          <select required className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
            value={form.roleId} onChange={(e) => setForm({ ...form, roleId: e.target.value })}>
            <option value="">— pilih —</option>
            {roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
          </select>
        </label>
        {err && <p className="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{err}</p>}
        <button disabled={busy} className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50">
          {busy ? 'Menyimpan...' : 'Simpan'}
        </button>
      </form>
    </div>
  );
}
```

- [ ] **Step 29.4:** Create `admin/users/[id]/page.js`

```javascript
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { api } from '@/lib/api';

export default function EditUser() {
  const { id } = useParams();
  const router = useRouter();
  const [user, setUser] = useState(null);
  const [roles, setRoles] = useState([]);
  const [err, setErr] = useState('');
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    api(`/users/${id}`).then(setUser);
    api('/roles').then(setRoles);
  }, [id]);

  if (!user) return <p className="text-gray-500">Memuat...</p>;

  const submit = async (e) => {
    e.preventDefault(); setErr(''); setBusy(true);
    try {
      await api(`/users/${id}`, { method: 'PATCH', body: {
        email: user.email, fullName: user.fullName, phone: user.phone,
        roleId: user.roleId, isActive: user.isActive,
      }});
      router.push('/admin/users');
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };

  return (
    <div className="max-w-md">
      <h1 className="mb-4 text-2xl font-semibold">Edit User: {user.username}</h1>
      <form onSubmit={submit} className="space-y-3 rounded border border-gray-200 bg-white p-4">
        <label className="block">
          <span className="block text-sm font-medium">Email</span>
          <input type="email" required className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
            value={user.email} onChange={(e) => setUser({ ...user, email: e.target.value })} />
        </label>
        <label className="block">
          <span className="block text-sm font-medium">Nama Lengkap</span>
          <input required className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
            value={user.fullName} onChange={(e) => setUser({ ...user, fullName: e.target.value })} />
        </label>
        <label className="block">
          <span className="block text-sm font-medium">Telepon</span>
          <input className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
            value={user.phone ?? ''} onChange={(e) => setUser({ ...user, phone: e.target.value || null })} />
        </label>
        <label className="block">
          <span className="block text-sm font-medium">Role</span>
          <select required className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
            value={user.roleId} onChange={(e) => setUser({ ...user, roleId: e.target.value })}>
            {roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
          </select>
        </label>
        <label className="flex items-center gap-2 text-sm">
          <input type="checkbox" checked={user.isActive} onChange={(e) => setUser({ ...user, isActive: e.target.checked })} />
          Aktif
        </label>
        {err && <p className="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{err}</p>}
        <button disabled={busy} className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50">
          {busy ? 'Menyimpan...' : 'Simpan'}
        </button>
      </form>
    </div>
  );
}
```

- [ ] **Step 29.5:** Commit

```bash
git add frontend/src/app/admin
git commit -m "feat(frontend): admin/users list, create, edit pages with reset/unlock/deactivate actions"
```

---

## Task 30: Frontend — admin/roles (list, create, edit + permission matrix)

**Files:**
- Create: `$ROOT/frontend/src/app/admin/roles/page.js`
- Create: `$ROOT/frontend/src/app/admin/roles/new/page.js`
- Create: `$ROOT/frontend/src/app/admin/roles/[id]/page.js`

- [ ] **Step 30.1:** Create `admin/roles/page.js`

```javascript
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { api } from '@/lib/api';

export default function RolesList() {
  const [items, setItems] = useState([]);
  const load = () => api('/roles').then(setItems);
  useEffect(() => { load(); }, []);

  const remove = async (id) => {
    if (!confirm('Hapus role ini?')) return;
    try { await api(`/roles/${id}`, { method: 'DELETE' }); load(); }
    catch (e) { alert(e.message); }
  };

  return (
    <div>
      <div className="mb-4 flex items-center justify-between">
        <h1 className="text-2xl font-semibold">Role &amp; Permission</h1>
        <Link href="/admin/roles/new" className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white">+ Tambah</Link>
      </div>
      <table className="w-full border-collapse border border-gray-200 bg-white text-sm">
        <thead><tr className="bg-gray-50 text-left">
          <th className="border border-gray-200 px-3 py-2">Nama</th>
          <th className="border border-gray-200 px-3 py-2">User</th>
          <th className="border border-gray-200 px-3 py-2">Permission</th>
          <th className="border border-gray-200 px-3 py-2">Tipe</th>
          <th className="border border-gray-200 px-3 py-2">Aksi</th>
        </tr></thead>
        <tbody>
          {items.map((r) => (
            <tr key={r.id}>
              <td className="border border-gray-200 px-3 py-2 font-medium">{r.name}</td>
              <td className="border border-gray-200 px-3 py-2">{r.userCount}</td>
              <td className="border border-gray-200 px-3 py-2">{r.permissionCount}</td>
              <td className="border border-gray-200 px-3 py-2">{r.isSystem ? <span className="text-amber-700">Sistem</span> : 'Custom'}</td>
              <td className="border border-gray-200 px-3 py-2 space-x-2">
                <Link href={`/admin/roles/${r.id}`} className="text-blue-700 hover:underline">Edit</Link>
                {!r.isSystem && <button onClick={() => remove(r.id)} className="text-red-700 hover:underline">Hapus</button>}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
```

- [ ] **Step 30.2:** Create `admin/roles/new/page.js`

```javascript
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';

export default function NewRole() {
  const router = useRouter();
  const [form, setForm] = useState({ name: '', description: '' });
  const [err, setErr] = useState('');
  const [busy, setBusy] = useState(false);

  const submit = async (e) => {
    e.preventDefault(); setErr(''); setBusy(true);
    try {
      const r = await api('/roles', { method: 'POST', body: form });
      router.push(`/admin/roles/${r.id}`);
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };

  return (
    <div className="max-w-md">
      <h1 className="mb-4 text-2xl font-semibold">Role Baru</h1>
      <form onSubmit={submit} className="space-y-3 rounded border border-gray-200 bg-white p-4">
        <label className="block">
          <span className="block text-sm font-medium">Nama</span>
          <input required className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
            value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
        </label>
        <label className="block">
          <span className="block text-sm font-medium">Deskripsi</span>
          <textarea className="mt-1 w-full rounded border border-gray-300 px-3 py-2 text-sm"
            value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
        </label>
        {err && <p className="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{err}</p>}
        <button disabled={busy} className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50">
          {busy ? 'Menyimpan...' : 'Simpan & Set Permission'}
        </button>
      </form>
    </div>
  );
}
```

- [ ] **Step 30.3:** Create `admin/roles/[id]/page.js` (matrix permission)

```javascript
'use client';
import { useEffect, useState, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { api } from '@/lib/api';

export default function EditRole() {
  const { id } = useParams();
  const router = useRouter();
  const [role, setRole] = useState(null);
  const [allPerms, setAllPerms] = useState([]);
  const [selected, setSelected] = useState(new Set());
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');

  useEffect(() => {
    api(`/roles/${id}`).then((r) => {
      setRole(r);
      setSelected(new Set(r.permissions.map((p) => p.id)));
    });
    api('/permissions').then(setAllPerms);
  }, [id]);

  const grouped = useMemo(() => {
    const map = new Map();
    for (const p of allPerms) {
      if (!map.has(p.module)) map.set(p.module, []);
      map.get(p.module).push(p);
    }
    return Array.from(map.entries());
  }, [allPerms]);

  const toggle = (pid) => {
    const next = new Set(selected);
    if (next.has(pid)) next.delete(pid); else next.add(pid);
    setSelected(next);
  };
  const toggleModule = (perms) => {
    const next = new Set(selected);
    const allOn = perms.every((p) => next.has(p.id));
    for (const p of perms) { if (allOn) next.delete(p.id); else next.add(p.id); }
    setSelected(next);
  };

  const save = async () => {
    setErr(''); setBusy(true);
    try {
      await api(`/roles/${id}/permissions`, { method: 'PUT',
        body: { permissionIds: Array.from(selected) } });
      router.push('/admin/roles');
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };

  if (!role) return <p className="text-gray-500">Memuat...</p>;

  return (
    <div>
      <h1 className="mb-1 text-2xl font-semibold">Role: {role.name}</h1>
      <p className="mb-4 text-sm text-gray-500">{role.isSystem ? 'Role sistem (tidak dapat diubah)' : 'Atur permission untuk role ini'}</p>
      <table className="w-full border-collapse border border-gray-200 bg-white text-sm">
        <thead><tr className="bg-gray-50 text-left">
          <th className="border border-gray-200 px-3 py-2 w-40">Modul</th>
          <th className="border border-gray-200 px-3 py-2">Permission</th>
        </tr></thead>
        <tbody>
          {grouped.map(([mod, perms]) => (
            <tr key={mod}>
              <td className="border border-gray-200 px-3 py-2 font-medium align-top">
                <button disabled={role.isSystem} onClick={() => toggleModule(perms)}
                  className="text-left text-blue-700 hover:underline disabled:text-gray-500 disabled:no-underline">{mod}</button>
              </td>
              <td className="border border-gray-200 px-3 py-2">
                <div className="flex flex-wrap gap-3">
                  {perms.map((p) => (
                    <label key={p.id} className="flex items-center gap-1.5">
                      <input type="checkbox" disabled={role.isSystem}
                        checked={selected.has(p.id)} onChange={() => toggle(p.id)} />
                      <span>{p.action} <span className="text-gray-400">— {p.label}</span></span>
                    </label>
                  ))}
                </div>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      {err && <p className="mt-3 rounded bg-red-50 px-3 py-2 text-sm text-red-700">{err}</p>}
      <div className="mt-4 flex gap-2">
        <button disabled={role.isSystem || busy || selected.size === 0} onClick={save}
          className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50">
          {busy ? 'Menyimpan...' : 'Simpan Permission'}
        </button>
      </div>
    </div>
  );
}
```

- [ ] **Step 30.4:** Commit

```bash
git add frontend/src/app/admin/roles
git commit -m "feat(frontend): admin/roles list + new + edit-with-permission-matrix"
```

---

## Task 31: Frontend — audit-log + profile

**Files:**
- Create: `$ROOT/frontend/src/app/admin/audit-log/page.js`
- Create: `$ROOT/frontend/src/app/profile/layout.js`
- Create: `$ROOT/frontend/src/app/profile/page.js`

- [ ] **Step 31.1:** Create `admin/audit-log/page.js`

```javascript
'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';

export default function AuditLog() {
  const [data, setData] = useState({ items: [], total: 0, page: 1, pageSize: 50 });
  const [filter, setFilter] = useState({ module: '', userId: '', action: '', result: '' });
  const [busy, setBusy] = useState(false);

  const load = async (page = 1) => {
    setBusy(true);
    try {
      const q = new URLSearchParams({ page, pageSize: 50 });
      for (const k of Object.keys(filter)) if (filter[k]) q.set(k, filter[k]);
      setData(await api(`/audit-log?${q.toString()}`));
    } finally { setBusy(false); }
  };
  useEffect(() => { load(1); }, []);

  return (
    <div>
      <h1 className="mb-4 text-2xl font-semibold">Audit Log</h1>
      <div className="mb-3 grid grid-cols-2 md:grid-cols-4 gap-2">
        {['module','userId','action'].map((f) => (
          <input key={f} placeholder={f}
            className="rounded border border-gray-300 px-3 py-1.5 text-sm"
            value={filter[f]} onChange={(e) => setFilter({ ...filter, [f]: e.target.value })} />
        ))}
        <select className="rounded border border-gray-300 px-3 py-1.5 text-sm"
          value={filter.result} onChange={(e) => setFilter({ ...filter, result: e.target.value })}>
          <option value="">all results</option>
          <option value="OK">OK</option><option value="FAIL">FAIL</option><option value="DENIED">DENIED</option>
        </select>
      </div>
      <button onClick={() => load(1)} className="mb-3 rounded border border-gray-300 px-3 py-1.5 text-sm">Terapkan filter</button>
      <table className="w-full border-collapse border border-gray-200 bg-white text-xs">
        <thead><tr className="bg-gray-50 text-left">
          <th className="border border-gray-200 px-2 py-1.5">Waktu</th>
          <th className="border border-gray-200 px-2 py-1.5">User</th>
          <th className="border border-gray-200 px-2 py-1.5">Modul</th>
          <th className="border border-gray-200 px-2 py-1.5">Aksi</th>
          <th className="border border-gray-200 px-2 py-1.5">Entity</th>
          <th className="border border-gray-200 px-2 py-1.5">IP</th>
          <th className="border border-gray-200 px-2 py-1.5">Hasil</th>
        </tr></thead>
        <tbody>
          {data.items.map((r) => (
            <tr key={r.id} className={r.result === 'DENIED' ? 'bg-amber-50' : r.result === 'FAIL' ? 'bg-red-50' : ''}>
              <td className="border border-gray-200 px-2 py-1.5">{new Date(r.at).toLocaleString()}</td>
              <td className="border border-gray-200 px-2 py-1.5">{r.username || '-'}</td>
              <td className="border border-gray-200 px-2 py-1.5">{r.module}</td>
              <td className="border border-gray-200 px-2 py-1.5">{r.action}</td>
              <td className="border border-gray-200 px-2 py-1.5">{r.entityId || '-'}</td>
              <td className="border border-gray-200 px-2 py-1.5">{r.ip || '-'}</td>
              <td className="border border-gray-200 px-2 py-1.5">{r.result}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="mt-2 text-xs text-gray-500">Total: {data.total} · Hal {data.page}</p>
      {busy && <p className="text-xs text-gray-400">Memuat...</p>}
    </div>
  );
}
```

- [ ] **Step 31.2:** Create `profile/layout.js` (reuse AppShell)

```javascript
import AppShell from '@/components/shells/AppShell';
export default function Layout({ children }) { return <AppShell>{children}</AppShell>; }
```

- [ ] **Step 31.3:** Create `profile/page.js`

```javascript
'use client';
import { useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { api } from '@/lib/api';

export default function Profile() {
  const { user, reload } = useAuth();
  const sp = useSearchParams();
  const router = useRouter();
  const force = sp.get('force') === '1';
  const [pw, setPw] = useState({ currentPassword: '', newPassword: '', confirm: '' });
  const [msg, setMsg] = useState('');
  const [err, setErr] = useState('');
  const [busy, setBusy] = useState(false);

  if (!user) return null;

  const change = async (e) => {
    e.preventDefault(); setMsg(''); setErr(''); setBusy(true);
    if (pw.newPassword !== pw.confirm) { setErr('Konfirmasi password tidak cocok'); setBusy(false); return; }
    try {
      await api('/auth/change-password', { method: 'POST',
        body: { currentPassword: pw.currentPassword, newPassword: pw.newPassword } });
      setMsg('Password berhasil diganti. Anda akan diarahkan ke dashboard.');
      await reload();
      setTimeout(() => router.push('/dashboard'), 1500);
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };

  return (
    <div className="max-w-md">
      <h1 className="mb-4 text-2xl font-semibold">Profil Saya</h1>
      <div className="mb-6 rounded border border-gray-200 bg-white p-4 text-sm">
        <p><b>Username:</b> {user.username}</p>
        <p><b>Email:</b> {user.email}</p>
        <p><b>Nama:</b> {user.fullName}</p>
        <p><b>Role:</b> {user.role?.name}</p>
      </div>
      {force && <p className="mb-3 rounded bg-amber-50 px-3 py-2 text-sm text-amber-800">
        Anda wajib mengganti password sebelum melanjutkan.
      </p>}
      <form onSubmit={change} className="space-y-3 rounded border border-gray-200 bg-white p-4">
        <h2 className="font-medium">Ganti Password</h2>
        <input type="password" required placeholder="Password saat ini"
          className="w-full rounded border border-gray-300 px-3 py-2 text-sm"
          value={pw.currentPassword} onChange={(e) => setPw({ ...pw, currentPassword: e.target.value })} />
        <input type="password" required minLength={8} placeholder="Password baru (min 8 char)"
          className="w-full rounded border border-gray-300 px-3 py-2 text-sm"
          value={pw.newPassword} onChange={(e) => setPw({ ...pw, newPassword: e.target.value })} />
        <input type="password" required placeholder="Konfirmasi password baru"
          className="w-full rounded border border-gray-300 px-3 py-2 text-sm"
          value={pw.confirm} onChange={(e) => setPw({ ...pw, confirm: e.target.value })} />
        {err && <p className="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{err}</p>}
        {msg && <p className="rounded bg-green-50 px-3 py-2 text-sm text-green-700">{msg}</p>}
        <button disabled={busy} className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50">
          {busy ? 'Memproses...' : 'Ganti Password'}
        </button>
      </form>
    </div>
  );
}
```

- [ ] **Step 31.4:** Commit

```bash
git add frontend/src/app/admin/audit-log frontend/src/app/profile
git commit -m "feat(frontend): audit-log table with filters + profile page with change-password"
```

---

## Task 32: Frontend — forgot-password placeholder + dashboard redirect home

**Files:**
- Create: `$ROOT/frontend/src/app/forgot-password/page.js`
- Modify: `$ROOT/frontend/src/app/page.js`

- [ ] **Step 32.1:** Create forgot-password placeholder

```javascript
import Link from 'next/link';
export default function ForgotPassword() {
  return (
    <main className="flex min-h-screen items-center justify-center px-4">
      <div className="max-w-sm rounded border border-gray-200 bg-white p-6 text-center">
        <h1 className="text-xl font-semibold">Lupa Password</h1>
        <p className="mt-3 text-sm text-gray-600">
          Silakan hubungi admin koperasi untuk reset password Anda. Sistem email otomatis akan
          tersedia di fase berikutnya.
        </p>
        <Link href="/login" className="mt-4 inline-block text-sm text-blue-700 hover:underline">
          ← Kembali ke login
        </Link>
      </div>
    </main>
  );
}
```

- [ ] **Step 32.2:** Update `app/page.js` untuk redirect ke dashboard jika sudah login

```javascript
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';

export default function Home() {
  const { user, loading } = useAuth();
  const router = useRouter();
  useEffect(() => {
    if (loading) return;
    router.replace(user ? '/dashboard' : '/login');
  }, [user, loading, router]);
  return <main className="flex min-h-screen items-center justify-center text-gray-500">Memuat...</main>;
}
```

- [ ] **Step 32.3:** Commit

```bash
git add frontend/src/app/forgot-password frontend/src/app/page.js
git commit -m "feat(frontend): forgot-password placeholder and root redirect by auth state"
```

---

## Task 33: Deployment files (PM2 + .htaccess)

**Files:**
- Create: `$ROOT/ecosystem.config.cjs`
- Create: `$ROOT/.htaccess`

- [ ] **Step 33.1:** Create `ecosystem.config.cjs`

```javascript
module.exports = {
  apps: [
    {
      name: 'premium-backend',
      cwd: './backend',
      script: 'dist/server.js',
      instances: 1,
      autorestart: true,
      max_memory_restart: '512M',
      env: { NODE_ENV: 'production' },
    },
    {
      name: 'premium-frontend',
      cwd: './frontend',
      script: 'node_modules/next/dist/bin/next',
      args: 'start -p 3100',
      instances: 1,
      autorestart: true,
      max_memory_restart: '512M',
      env: { NODE_ENV: 'production' },
    },
    {
      name: 'premium-worker',
      cwd: './backend',
      script: 'dist/workers/index.js',
      instances: 2,
      autorestart: true,
      max_memory_restart: '256M',
      env: { NODE_ENV: 'production' },
    },
  ],
};
```

- [ ] **Step 33.2:** Create `.htaccess`

```apache
# Premium Koperasi proxy ke Node
RewriteEngine On

# API → backend port 4100
RewriteCond %{REQUEST_URI} ^/api/
RewriteRule ^api/(.*)$ http://127.0.0.1:4100/api/$1 [P,L]

# Everything else → Next.js frontend port 3100
RewriteRule ^(.*)$ http://127.0.0.1:3100/$1 [P,L]

# Security headers tambahan (helmet juga sudah set, ini fallback Apache)
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
```

Catatan: kalau cPanel butuh `ProxyPass` daripada mod_rewrite proxy, sesuaikan dengan rule dari hosting provider (kumpehhost).

- [ ] **Step 33.3:** Verifikasi build production

```bash
cd $ROOT/backend && npm run build && ls -la dist/server.js dist/workers/index.js
cd $ROOT/frontend && npm run build
```
Expected: dist files ada; Next.js build success.

- [ ] **Step 33.4:** Start via PM2 (test lokal di server)

```bash
cd $ROOT
# pastikan .env backend sudah benar (DATABASE_URL, JWT_SECRET, KOPDES_DIR)
mkdir -p /home/developerkdmpmy/kopdes
pm2 start ecosystem.config.cjs
pm2 status
pm2 logs --lines 20
```
Expected: 3 process online (premium-backend, premium-frontend, premium-worker).

- [ ] **Step 33.5:** Test akhir-ke-akhir via HTTP lokal

```bash
curl -i http://127.0.0.1:4100/api/health   # { db: 'ok' }
curl -I http://127.0.0.1:3100              # 200 / 307 redirect
# via subdomain (kalau DNS sudah jalan):
curl -I https://premium.developerkdmp.my.id/api/health
```

- [ ] **Step 33.6:** Save PM2 + setup startup

```bash
pm2 save
# pm2 startup butuh root, di shared cPanel pakai cron @reboot sebagai alternatif:
crontab -l > /tmp/cron.bak 2>/dev/null
echo "@reboot /home/developerkdmpmy/.nvm/versions/node/v24.15.0/bin/pm2 resurrect" >> /tmp/cron.bak
crontab /tmp/cron.bak
```

- [ ] **Step 33.7:** Commit

```bash
git add ecosystem.config.cjs .htaccess
git commit -m "chore(deploy): PM2 ecosystem and Apache htaccess proxy for cPanel"
```

---

## Task 34: E2E Smoke (Playwright)

**Files:**
- Create: `$ROOT/frontend/playwright.config.js`
- Create: `$ROOT/frontend/e2e/smoke.spec.js`
- Modify: `$ROOT/frontend/package.json` (add playwright dep + script)

- [ ] **Step 34.1:** Install Playwright

```bash
cd $ROOT/frontend
npm install -D @playwright/test
npx playwright install chromium
```

- [ ] **Step 34.2:** Add scripts to `frontend/package.json`

```json
"scripts": {
  "dev": "next dev -p 3100",
  "build": "next build",
  "start": "next start -p 3100",
  "lint": "next lint",
  "e2e": "playwright test"
}
```

- [ ] **Step 34.3:** Create `playwright.config.js`

```javascript
import { defineConfig } from '@playwright/test';
export default defineConfig({
  testDir: './e2e',
  timeout: 30_000,
  use: { baseURL: 'http://127.0.0.1:3100', headless: true },
  reporter: 'list',
});
```

- [ ] **Step 34.4:** Create `e2e/smoke.spec.js`

```javascript
import { test, expect } from '@playwright/test';

const SUPER_USER = process.env.E2E_USER || 'superadmin';
const SUPER_PASS = process.env.E2E_PASS; // wajib pass via env

test.skip(!SUPER_PASS, 'E2E_PASS env not set');

test('login → dashboard → users → audit', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Username atau Email').fill(SUPER_USER);
  await page.getByLabel('Password').fill(SUPER_PASS);
  await page.getByRole('button', { name: 'Masuk' }).click();

  // first login mungkin diarahkan ke profile?force=1; kalau ya, isi password sama (skip atau set ulang)
  if (page.url().includes('/profile')) {
    test.info().annotations.push({ type: 'note', value: 'force-change-password screen detected' });
    return; // smoke berhenti di sini; manual setup harus reset mustChangePass=false dulu kalau mau lanjut
  }

  await expect(page).toHaveURL(/\/dashboard$/);
  await page.getByRole('link', { name: 'User' }).click();
  await expect(page.getByRole('heading', { name: 'User' })).toBeVisible();
  await page.getByRole('link', { name: 'Audit Log' }).click();
  await expect(page.getByRole('heading', { name: 'Audit Log' })).toBeVisible();
});
```

- [ ] **Step 34.5:** Run smoke (dengan backend+frontend hidup, dan password superadmin valid yang sudah diganti)

```bash
cd $ROOT/frontend
E2E_PASS='<password yg sudah diganti>' npm run e2e
```
Expected: 1 test passed.

- [ ] **Step 34.6:** Commit

```bash
git add frontend/playwright.config.js frontend/e2e frontend/package.json frontend/package-lock.json
git commit -m "test(e2e): playwright smoke for login + dashboard + admin navigation"
```

---

## Task 35: DoD Verification & Final Commit

**Goal:** Pastikan semua Definition of Done Fase 0 terpenuhi, dokumentasikan kredensial & catat status.

- [ ] **Step 35.1:** Jalankan semua test backend

```bash
cd $ROOT/backend && npm run typecheck && npm test
```
Expected: typecheck 0 error; semua test PASS.

- [ ] **Step 35.2:** Jalankan lint & build frontend

```bash
cd $ROOT/frontend && npm run lint && npm run build
```
Expected: lint clean (atau hanya warning), build success.

- [ ] **Step 35.3:** Verifikasi pattern migrasi (dump + tar + restore di environment lain)

```bash
# Dump
pg_dump "$DATABASE_URL" | gzip > /tmp/premium-db-test.sql.gz
tar czf /tmp/kopdes-test.tar.gz -C /home/developerkdmpmy kopdes
ls -la /tmp/premium-db-test.sql.gz /tmp/kopdes-test.tar.gz
```
Expected: 2 file ada dan size > 0.

(Restore test di environment lain dilakukan saat actual migrasi; di sini cukup dump berhasil.)

- [ ] **Step 35.4:** Buka halaman lewat subdomain & verifikasi

Buka di browser https://premium.developerkdmp.my.id :
- `/login` muncul → login superadmin → diarahkan ke `/profile?force=1`
- Ganti password → diarahkan `/dashboard`
- Klik User → daftar muncul (1 superadmin)
- Buat 1 user test dengan role baru → assign permission `user:read` → login dengan user itu → sidebar hanya menampilkan menu yang sesuai
- Klik Audit Log → minimal terlihat baris LOGIN_OK & USER_CREATE & ROLE_SET_PERMS

- [ ] **Step 35.5:** Tag release Fase 0

```bash
cd $ROOT
git tag -a v0.1.0-fase0 -m "Fase 0 Foundation — release"
git log --oneline -1
```

- [ ] **Step 35.6:** Update README dengan status Fase 0 = DONE

Edit `$ROOT/README.md`, di section Roadmap ganti:

```markdown
## Roadmap
- ✅ Fase 0: Foundation (identitas, akses, audit, queue, file) — **selesai** (v0.1.0-fase0, 2026-05-26)
- Fase 1: Keanggotaan
- ...
```

- [ ] **Step 35.7:** Commit & finish

```bash
git add README.md
git commit -m "docs: mark Fase 0 Foundation as completed"
```

---

## Lampiran A: Mapping Spec → Task

| Spec section | Tasks |
|---|---|
| 2. Tech Stack | Task 2, 3 |
| 3. Struktur Folder | Task 1, 2, 3 (+ Task 33 untuk deployment) |
| 4. Model Data | Task 4 (semua model di schema sekaligus) |
| 5.1 Login | Task 17 |
| 5.2 Refresh & Logout | Task 17 |
| 5.3 RBAC | Task 14 |
| 5.4 Idempotency | Task 15 |
| 5.5 Antrian Transaksi | Task 9, 23 |
| 5.6 Upload File | Task 10, 21 |
| 5.7 Housekeeping | Task 23 |
| 6. Endpoint API | Task 17-22 |
| 7. Halaman Frontend | Task 25-32 |
| 8. Seed Awal | Task 5 |
| 9. Konfigurasi Deployment | Task 33 |
| 10. Testing Strategy | semua task punya test step; E2E di Task 34 |
| 11. Risiko (lockedBy reset stuck) | Task 9 (resetStuckJobs) + Task 23 |
| 12. DoD Fase 0 | Task 35 |

## Lampiran B: Catatan Eksekusi

- **Urutan task harus dijalankan berurutan** karena ada dependency (mis: Task 13 butuh Task 7 jwt lib).
- **Test DB**: semua test integration jalan di DB yang sama (`developerkdmpmy_premium_pg`) — pakai prefix unique untuk username/role test agar tidak bentrok antar test file. Pola sudah diikuti di task.
- **Password seed**: catat password superadmin saat Task 5 dijalankan — kalau hilang, jalankan Task 24 script.
- **Cookie httpOnly**: di local dev (`COOKIE_SECURE=false`) browser akan terima cookie via http; di production set `COOKIE_SECURE=true` agar hanya via https.
- **Jika ada test gagal karena Apache proxy belum aktif**: itu normal di Task 33 sebelum cPanel di-konfigurasi; verifikasi via 127.0.0.1 dulu, lalu test subdomain.

