# Fase 1A — Pondasi: Scaffold + Auth + RBAC + Keanggotaan — 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:** Membangun pondasi backend Koperasi Merah Putih — project scaffold, koneksi PostgreSQL via Prisma, autentikasi JWT (HttpOnly + refresh rotation), RBAC dinamis (role & izin bisa diubah dari data), dan modul keanggotaan — semua dengan test lulus.

**Architecture:** Express + TypeScript dengan Clean Architecture modular. Tiap modul = folder berisi `controller → service → repository` di atas satu *shared kernel* (config, error RFC 7807, jwt, hashing, rbac middleware). Single-tenant, PostgreSQL 13 + Prisma. Tanpa Redis/Docker.

**Tech Stack:** Node 24, Express 4, TypeScript 5, Prisma 5, PostgreSQL 13, zod (validasi), argon2 (hash), jsonwebtoken (JWT), cookie-parser, helmet, vitest (test), supertest (HTTP test).

**Lokasi:** `/home/developerkdmpmy/kopdes.developerkdmp.my.id/backend/`

---

## Prasyarat (dikerjakan manual sekali, di luar TDD)

### Task 0: Siapkan database & environment

**Aksi manual via cPanel → PostgreSQL Databases** (ikut konvensi project `layanan`: `_pg` + `_shadow`):
- [ ] Buat user DB `developerkdmpmy_kopdes` (1 user dipakai untuk semua DB di bawah).
- [ ] Buat database `developerkdmpmy_kopdes_pg` — **database utama** (`DATABASE_URL`), beri privilege penuh ke user di atas.
- [ ] Buat database `developerkdmpmy_kopdes_shadow` — **shadow DB** untuk Prisma `migrate dev` (`SHADOW_DATABASE_URL`). Wajib karena di shared cPanel user DB tidak bisa `CREATE/DROP DATABASE`; Prisma memakai shadow DB kosong yang sudah dibuat manual untuk menghitung migrasi.
- [ ] Buat database `developerkdmpmy_kopdes_test` — **integration test**. (Opsional bila hanya mau 2 DB; tapi sangat disarankan untuk app finansial.)
- [ ] Catat kredensial untuk `DATABASE_URL` & `SHADOW_DATABASE_URL`.

**Aksi shell:**
- [ ] Aktifkan Node via nvm:

Run:
```bash
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"; node -v
```
Expected: `v24.15.0`

---

## Task 1: Scaffold backend + server health

**Files:**
- Create: `backend/package.json`
- Create: `backend/tsconfig.json`
- Create: `backend/vitest.config.ts`
- Create: `backend/.env.example`
- Create: `backend/src/app.ts`
- Create: `backend/src/main.ts`
- Test: `backend/test/health.test.ts`

- [ ] **Step 1: Buat `backend/package.json`**

```json
{
  "name": "kopdes-backend",
  "version": "0.1.0",
  "private": true,
  "type": "commonjs",
  "scripts": {
    "dev": "ts-node-dev --respawn src/main.ts",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/main.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "prisma:generate": "prisma generate",
    "prisma:migrate": "prisma migrate dev",
    "seed": "ts-node prisma/seed.ts"
  },
  "dependencies": {
    "@prisma/client": "^5.22.0",
    "argon2": "^0.41.1",
    "cookie-parser": "^1.4.7",
    "cors": "^2.8.5",
    "express": "^4.21.2",
    "helmet": "^8.0.0",
    "jsonwebtoken": "^9.0.2",
    "zod": "^3.24.1"
  },
  "devDependencies": {
    "@types/cookie-parser": "^1.4.8",
    "@types/cors": "^2.8.17",
    "@types/express": "^4.17.21",
    "@types/jsonwebtoken": "^9.0.7",
    "@types/node": "^22.10.0",
    "@types/supertest": "^6.0.2",
    "prisma": "^5.22.0",
    "supertest": "^7.0.0",
    "ts-node": "^10.9.2",
    "ts-node-dev": "^2.0.0",
    "typescript": "^5.7.2",
    "vitest": "^2.1.8"
  }
}
```

- [ ] **Step 2: Buat `backend/tsconfig.json`**

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "moduleResolution": "Node",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "sourceMap": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist", "test"]
}
```

- [ ] **Step 3: Buat `backend/vitest.config.ts`**

```ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: { environment: 'node', include: ['test/**/*.test.ts'], hookTimeout: 30000 },
});
```

- [ ] **Step 4: Buat `backend/.env.example`**

```bash
NODE_ENV=development
PORT=4010
HOST=127.0.0.1
DATABASE_URL="postgresql://USER:PASS@127.0.0.1:5432/developerkdmpmy_kopdes_pg?schema=public"
SHADOW_DATABASE_URL="postgresql://USER:PASS@127.0.0.1:5432/developerkdmpmy_kopdes_shadow?schema=public"
JWT_ACCESS_SECRET="ganti-dengan-string-acak-panjang"
JWT_REFRESH_SECRET="ganti-dengan-string-acak-panjang-2"
ACCESS_TOKEN_TTL=900
REFRESH_TOKEN_TTL=604800
COOKIE_DOMAIN=kopdes.developerkdmp.my.id
CORS_ORIGIN=https://kopdes.developerkdmp.my.id
```

- [ ] **Step 5: Install dependencies**

Run:
```bash
cd /home/developerkdmpmy/kopdes.developerkdmp.my.id/backend && \
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh" && npm install
```
Expected: `node_modules/` terbuat, tanpa error fatal.

- [ ] **Step 6: Buat `backend/src/app.ts` (factory Express, tanpa listen)**

```ts
import express, { Express } from 'express';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';

export function createApp(): Express {
  const app = express();
  app.use(helmet());
  app.use(cors({ origin: process.env.CORS_ORIGIN?.split(',') ?? true, credentials: true }));
  app.use(express.json());
  app.use(cookieParser());

  app.get('/api/v1/health', (_req, res) => res.json({ data: { status: 'ok' } }));

  return app;
}
```

- [ ] **Step 7: Buat `backend/src/main.ts` (entrypoint)**

```ts
import { createApp } from './app';

const app = createApp();
const port = Number(process.env.PORT ?? 4010);
const host = process.env.HOST ?? '127.0.0.1';
app.listen(port, host, () => console.log(`kopdes-backend listening on http://${host}:${port}`));
```

- [ ] **Step 8: Tulis test gagal `backend/test/health.test.ts`**

```ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { createApp } from '../src/app';

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

- [ ] **Step 9: Jalankan test**

Run: `npm test -- health`
Expected: PASS (1 test).

- [ ] **Step 10: Commit**

```bash
cd /home/developerkdmpmy/kopdes.developerkdmp.my.id
git add backend/package.json backend/tsconfig.json backend/vitest.config.ts backend/.env.example backend/src backend/test
git commit -m "feat(backend): scaffold express + health endpoint"
```

---

## Task 2: Prisma — skema auth/RBAC/anggota + migrasi

**Files:**
- Create: `backend/prisma/schema.prisma`
- Create: `backend/src/shared/prisma.ts`
- Create: `backend/.env` (dari `.env.example`, isi kredensial asli — JANGAN commit)

- [ ] **Step 1: Buat `backend/prisma/schema.prisma`** (subset Fase 1A — auth, rbac, anggota; model finansial menyusul di plan 1B)

```prisma
generator client { provider = "prisma-client-js" }
datasource db {
  provider          = "postgresql"
  url               = env("DATABASE_URL")
  shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}

enum UserStatus { ACTIVE SUSPENDED PENDING }
enum MemberStatus { ACTIVE INACTIVE RESIGNED }

model User {
  id            String     @id @default(uuid()) @db.Uuid
  nama          String
  nomorTelepon  String     @unique @map("nomor_telepon")
  email         String?    @unique
  passwordHash  String     @map("password_hash")
  status        UserStatus @default(ACTIVE)
  member        Member?
  roles         UserRole[]
  refreshTokens RefreshToken[]
  createdAt     DateTime   @default(now()) @map("created_at")
  updatedAt     DateTime   @updatedAt @map("updated_at")
  @@map("users")
}

model Role {
  id          String           @id @default(uuid()) @db.Uuid
  nama        String           @unique
  deskripsi   String?
  isSystem    Boolean          @default(false) @map("is_system")
  users       UserRole[]
  permissions RolePermission[]
  @@map("roles")
}

model Permission {
  id    String           @id @default(uuid()) @db.Uuid
  kode  String           @unique
  modul String
  roles RolePermission[]
  @@map("permissions")
}

model UserRole {
  userId String @map("user_id") @db.Uuid
  roleId String @map("role_id") @db.Uuid
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  role   Role   @relation(fields: [roleId], references: [id], onDelete: Cascade)
  @@id([userId, roleId])
  @@map("user_roles")
}

model RolePermission {
  roleId       String     @map("role_id") @db.Uuid
  permissionId String     @map("permission_id") @db.Uuid
  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 RefreshToken {
  id        String    @id @default(uuid()) @db.Uuid
  userId    String    @map("user_id") @db.Uuid
  tokenHash String    @unique @map("token_hash")
  expiresAt DateTime  @map("expires_at")
  revokedAt DateTime? @map("revoked_at")
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@map("refresh_tokens")
}

model Member {
  id            String       @id @default(uuid()) @db.Uuid
  userId        String       @unique @map("user_id") @db.Uuid
  nomorAnggota  String       @unique @map("nomor_anggota")
  nik           String?      @unique
  alamat        String?
  tanggalGabung DateTime     @default(now()) @map("tanggal_gabung")
  status        MemberStatus @default(ACTIVE)
  user          User         @relation(fields: [userId], references: [id])
  @@map("members")
}

model Setting {
  key   String @id
  value Json
  @@map("settings")
}
```

- [ ] **Step 2: Buat `backend/.env`** dari `.env.example`, isi `DATABASE_URL` (→ `..._kopdes_pg`) + `SHADOW_DATABASE_URL` (→ `..._kopdes_shadow`) kredensial asli + secret JWT acak (`openssl rand -hex 32`).

- [ ] **Step 3: Jalankan migrasi pertama**

Run:
```bash
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
npx prisma migrate dev --name init_auth_rbac_member
```
Expected: migrasi terbuat di `prisma/migrations/`, tabel terbuat di DB, Prisma Client tergenerate.

- [ ] **Step 4: Buat singleton `backend/src/shared/prisma.ts`**

```ts
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
```

- [ ] **Step 5: Commit**

```bash
cd /home/developerkdmpmy/kopdes.developerkdmp.my.id
git add backend/prisma backend/src/shared/prisma.ts
git commit -m "feat(backend): prisma schema auth/rbac/member + migrasi init"
```
> `.env` tidak ikut karena `.gitignore`.

---

## Task 3: Shared kernel — config loader (zod)

**Files:**
- Create: `backend/src/shared/config.ts`
- Test: `backend/test/config.test.ts`

- [ ] **Step 1: Tulis test gagal `backend/test/config.test.ts`**

```ts
import { describe, it, expect } from 'vitest';
import { loadConfig } from '../src/shared/config';

describe('loadConfig', () => {
  it('memvalidasi env wajib', () => {
    const cfg = loadConfig({
      DATABASE_URL: 'postgresql://x', JWT_ACCESS_SECRET: 'a'.repeat(16),
      JWT_REFRESH_SECRET: 'b'.repeat(16),
    });
    expect(cfg.accessTokenTtl).toBe(900);
    expect(cfg.port).toBe(4010);
  });
  it('melempar bila secret kurang', () => {
    expect(() => loadConfig({ DATABASE_URL: 'x' } as any)).toThrow();
  });
});
```

- [ ] **Step 2: Jalankan test**

Run: `npm test -- config`
Expected: FAIL ("Cannot find module config").

- [ ] **Step 3: Implementasi `backend/src/shared/config.ts`**

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

const schema = z.object({
  NODE_ENV: z.string().default('development'),
  PORT: z.coerce.number().default(4010),
  HOST: z.string().default('127.0.0.1'),
  DATABASE_URL: z.string().min(1),
  JWT_ACCESS_SECRET: z.string().min(16),
  JWT_REFRESH_SECRET: z.string().min(16),
  ACCESS_TOKEN_TTL: z.coerce.number().default(900),
  REFRESH_TOKEN_TTL: z.coerce.number().default(604800),
  COOKIE_DOMAIN: z.string().optional(),
  CORS_ORIGIN: z.string().optional(),
});

export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
  const p = schema.parse(env);
  return {
    nodeEnv: p.NODE_ENV, port: p.PORT, host: p.HOST, databaseUrl: p.DATABASE_URL,
    jwtAccessSecret: p.JWT_ACCESS_SECRET, jwtRefreshSecret: p.JWT_REFRESH_SECRET,
    accessTokenTtl: p.ACCESS_TOKEN_TTL, refreshTokenTtl: p.REFRESH_TOKEN_TTL,
    cookieDomain: p.COOKIE_DOMAIN, corsOrigin: p.CORS_ORIGIN,
  };
}
export type AppConfig = ReturnType<typeof loadConfig>;
```

- [ ] **Step 4: Jalankan test**

Run: `npm test -- config`
Expected: PASS (2 test).

- [ ] **Step 5: Commit**

```bash
git add backend/src/shared/config.ts backend/test/config.test.ts
git commit -m "feat(backend): config loader tervalidasi zod"
```

---

## Task 4: Shared kernel — error RFC 7807 + middleware

**Files:**
- Create: `backend/src/shared/errors.ts`
- Create: `backend/src/shared/error-middleware.ts`
- Test: `backend/test/errors.test.ts`

- [ ] **Step 1: Tulis test gagal `backend/test/errors.test.ts`**

```ts
import { describe, it, expect } from 'vitest';
import express from 'express';
import request from 'supertest';
import { AppError } from '../src/shared/errors';
import { errorMiddleware } from '../src/shared/error-middleware';

describe('errorMiddleware', () => {
  it('memetakan AppError ke Problem Details RFC 7807', async () => {
    const app = express();
    app.get('/x', () => { throw new AppError(422, 'insufficient-funds', 'Saldo kurang'); });
    app.use(errorMiddleware);
    const res = await request(app).get('/x');
    expect(res.status).toBe(422);
    expect(res.body.type).toContain('insufficient-funds');
    expect(res.body.title).toBe('Saldo kurang');
    expect(res.body.status).toBe(422);
  });
});
```

- [ ] **Step 2: Jalankan test**

Run: `npm test -- errors`
Expected: FAIL.

- [ ] **Step 3: Implementasi `backend/src/shared/errors.ts`**

```ts
export class AppError extends Error {
  constructor(
    public readonly status: number,
    public readonly code: string,
    message: string,
    public readonly detail?: string,
  ) { super(message); }
}
export const Errors = {
  unauthorized: (m = 'Tidak terautentikasi') => new AppError(401, 'unauthorized', m),
  forbidden:    (m = 'Tidak diizinkan')      => new AppError(403, 'forbidden', m),
  notFound:     (m = 'Tidak ditemukan')      => new AppError(404, 'not-found', m),
  conflict:     (m: string)                  => new AppError(409, 'conflict', m),
  validation:   (m: string, d?: string)      => new AppError(400, 'validation-error', m, d),
};
```

- [ ] **Step 4: Implementasi `backend/src/shared/error-middleware.ts`**

```ts
import { ErrorRequestHandler } from 'express';
import { ZodError } from 'zod';
import { AppError } from './errors';

export const errorMiddleware: ErrorRequestHandler = (err, req, res, _next) => {
  if (err instanceof AppError) {
    return res.status(err.status).json({
      type: `https://kopdes/errors/${err.code}`, title: err.message,
      status: err.status, detail: err.detail, instance: req.originalUrl,
    });
  }
  if (err instanceof ZodError) {
    return res.status(400).json({
      type: 'https://kopdes/errors/validation-error', title: 'Input tidak valid',
      status: 400, detail: err.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '),
      instance: req.originalUrl,
    });
  }
  console.error('UNHANDLED', err);
  return res.status(500).json({
    type: 'https://kopdes/errors/internal', title: 'Kesalahan server',
    status: 500, instance: req.originalUrl,
  });
};
```

- [ ] **Step 5: Jalankan test**

Run: `npm test -- errors`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add backend/src/shared/errors.ts backend/src/shared/error-middleware.ts backend/test/errors.test.ts
git commit -m "feat(backend): error RFC 7807 + middleware"
```

---

## Task 5: Shared kernel — hashing password (argon2)

**Files:**
- Create: `backend/src/shared/hash.ts`
- Test: `backend/test/hash.test.ts`

- [ ] **Step 1: Tulis test gagal `backend/test/hash.test.ts`**

```ts
import { describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword } from '../src/shared/hash';

describe('hash', () => {
  it('hash & verify cocok', async () => {
    const h = await hashPassword('rahasia123');
    expect(h).not.toBe('rahasia123');
    expect(await verifyPassword(h, 'rahasia123')).toBe(true);
    expect(await verifyPassword(h, 'salah')).toBe(false);
  });
});
```

- [ ] **Step 2: Jalankan test** — Run: `npm test -- hash` — Expected: FAIL.

- [ ] **Step 3: Implementasi `backend/src/shared/hash.ts`**

```ts
import argon2 from 'argon2';
export const hashPassword = (pwd: string) => argon2.hash(pwd, { type: argon2.argon2id });
export const verifyPassword = (hash: string, pwd: string) => argon2.verify(hash, pwd).catch(() => false);
```

- [ ] **Step 4: Jalankan test** — Run: `npm test -- hash` — Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add backend/src/shared/hash.ts backend/test/hash.test.ts
git commit -m "feat(backend): hashing password argon2id"
```

---

## Task 6: Shared kernel — JWT (access + refresh)

**Files:**
- Create: `backend/src/shared/jwt.ts`
- Test: `backend/test/jwt.test.ts`

- [ ] **Step 1: Tulis test gagal `backend/test/jwt.test.ts`**

```ts
import { describe, it, expect } from 'vitest';
import { signAccess, verifyAccess } from '../src/shared/jwt';

const cfg = { jwtAccessSecret: 'a'.repeat(32), jwtRefreshSecret: 'b'.repeat(32),
  accessTokenTtl: 900, refreshTokenTtl: 604800 } as any;

describe('jwt', () => {
  it('sign lalu verify access token', () => {
    const token = signAccess(cfg, { sub: 'user-1', permissions: ['anggota:read'] });
    const payload = verifyAccess(cfg, token);
    expect(payload.sub).toBe('user-1');
    expect(payload.permissions).toContain('anggota:read');
  });
  it('verify token rusak melempar', () => {
    expect(() => verifyAccess(cfg, 'bukan.token.valid')).toThrow();
  });
});
```

- [ ] **Step 2: Jalankan test** — Run: `npm test -- jwt` — Expected: FAIL.

- [ ] **Step 3: Implementasi `backend/src/shared/jwt.ts`**

```ts
import jwt from 'jsonwebtoken';
import type { AppConfig } from './config';

export interface AccessPayload { sub: string; permissions: string[]; }

export function signAccess(cfg: Pick<AppConfig,'jwtAccessSecret'|'accessTokenTtl'>, p: AccessPayload): string {
  return jwt.sign(p, cfg.jwtAccessSecret, { expiresIn: cfg.accessTokenTtl });
}
export function verifyAccess(cfg: Pick<AppConfig,'jwtAccessSecret'>, token: string): AccessPayload {
  return jwt.verify(token, cfg.jwtAccessSecret) as AccessPayload;
}
export function signRefresh(cfg: Pick<AppConfig,'jwtRefreshSecret'|'refreshTokenTtl'>, sub: string): string {
  return jwt.sign({ sub }, cfg.jwtRefreshSecret, { expiresIn: cfg.refreshTokenTtl });
}
export function verifyRefresh(cfg: Pick<AppConfig,'jwtRefreshSecret'>, token: string): { sub: string } {
  return jwt.verify(token, cfg.jwtRefreshSecret) as { sub: string };
}
```

- [ ] **Step 4: Jalankan test** — Run: `npm test -- jwt` — Expected: PASS (2 test).

- [ ] **Step 5: Commit**

```bash
git add backend/src/shared/jwt.ts backend/test/jwt.test.ts
git commit -m "feat(backend): util jwt access + refresh"
```

---

## Task 7: RBAC — resolusi izin & middleware

**Files:**
- Create: `backend/src/shared/rbac.ts`
- Test: `backend/test/rbac.test.ts`

- [ ] **Step 1: Tulis test gagal `backend/test/rbac.test.ts`**

```ts
import { describe, it, expect } from 'vitest';
import { requirePermission } from '../src/shared/rbac';
import { AppError } from '../src/shared/errors';

function fakeRes() { return {} as any; }

describe('requirePermission', () => {
  it('lolos bila punya izin', () => {
    const req: any = { user: { sub: 'u1', permissions: ['pinjaman:approve'] } };
    let called = false;
    requirePermission('pinjaman:approve')(req, fakeRes(), () => { called = true; });
    expect(called).toBe(true);
  });
  it('403 bila tak punya izin', () => {
    const req: any = { user: { sub: 'u1', permissions: ['anggota:read'] } };
    let err: unknown;
    requirePermission('pinjaman:approve')(req, fakeRes(), (e?: unknown) => { err = e; });
    expect(err).toBeInstanceOf(AppError);
    expect((err as AppError).status).toBe(403);
  });
  it('401 bila belum login', () => {
    const req: any = {};
    let err: unknown;
    requirePermission('anggota:read')(req, fakeRes(), (e?: unknown) => { err = e; });
    expect((err as AppError).status).toBe(401);
  });
});
```

- [ ] **Step 2: Jalankan test** — Run: `npm test -- rbac` — Expected: FAIL.

- [ ] **Step 3: Implementasi `backend/src/shared/rbac.ts`**

```ts
import { RequestHandler } from 'express';
import { Errors } from './errors';
import type { AccessPayload } from './jwt';

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

export function requirePermission(perm: string): RequestHandler {
  return (req, _res, next) => {
    if (!req.user) return next(Errors.unauthorized());
    if (!req.user.permissions.includes(perm)) return next(Errors.forbidden(`Butuh izin: ${perm}`));
    return next();
  };
}
```

- [ ] **Step 4: Jalankan test** — Run: `npm test -- rbac` — Expected: PASS (3 test).

- [ ] **Step 5: Commit**

```bash
git add backend/src/shared/rbac.ts backend/test/rbac.test.ts
git commit -m "feat(backend): rbac requirePermission middleware"
```

---

## Task 8: Auth — repository + service (register)

**Files:**
- Create: `backend/src/modules/auth/auth.repository.ts`
- Create: `backend/src/modules/auth/auth.service.ts`
- Test: `backend/test/auth.service.test.ts`

- [ ] **Step 1: Tulis test gagal `backend/test/auth.service.test.ts`** (repository di-mock — unit test murni)

```ts
import { describe, it, expect, vi } from 'vitest';
import { AuthService } from '../src/modules/auth/auth.service';

const cfg = { jwtAccessSecret: 'a'.repeat(32), jwtRefreshSecret: 'b'.repeat(32),
  accessTokenTtl: 900, refreshTokenTtl: 604800 } as any;

function makeRepo() {
  return {
    findByPhone: vi.fn(),
    createUser: vi.fn(),
    savePermissionsFor: vi.fn(),
    saveRefreshToken: vi.fn(),
    findRefreshToken: vi.fn(),
    revokeRefreshToken: vi.fn(),
    getPermissions: vi.fn().mockResolvedValue([]),
  };
}

describe('AuthService.register', () => {
  it('menolak nomor telepon duplikat', async () => {
    const repo = makeRepo();
    repo.findByPhone.mockResolvedValue({ id: 'ada' });
    const svc = new AuthService(repo as any, cfg);
    await expect(svc.register({ nama: 'A', nomorTelepon: '0811', password: 'rahasia123' }))
      .rejects.toThrow();
  });
  it('membuat user dengan password ter-hash', async () => {
    const repo = makeRepo();
    repo.findByPhone.mockResolvedValue(null);
    repo.createUser.mockImplementation(async (u: any) => ({ id: 'u1', ...u }));
    const svc = new AuthService(repo as any, cfg);
    const out = await svc.register({ nama: 'A', nomorTelepon: '0811', password: 'rahasia123' });
    expect(out.id).toBe('u1');
    const passed = repo.createUser.mock.calls[0][0];
    expect(passed.passwordHash).not.toBe('rahasia123');
  });
});
```

- [ ] **Step 2: Jalankan test** — Run: `npm test -- auth.service` — Expected: FAIL.

- [ ] **Step 3: Implementasi `backend/src/modules/auth/auth.repository.ts`**

```ts
import { prisma } from '../../shared/prisma';

export class AuthRepository {
  findByPhone(nomorTelepon: string) {
    return prisma.user.findUnique({ where: { nomorTelepon } });
  }
  createUser(data: { nama: string; nomorTelepon: string; passwordHash: string }) {
    return prisma.user.create({ data });
  }
  async getPermissions(userId: string): Promise<string[]> {
    const rows = await prisma.rolePermission.findMany({
      where: { role: { users: { some: { userId } } } },
      select: { permission: { select: { kode: true } } },
    });
    return [...new Set(rows.map(r => r.permission.kode))];
  }
  saveRefreshToken(userId: string, tokenHash: string, expiresAt: Date) {
    return prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } });
  }
  findRefreshToken(tokenHash: string) {
    return prisma.refreshToken.findUnique({ where: { tokenHash } });
  }
  revokeRefreshToken(tokenHash: string) {
    return prisma.refreshToken.update({ where: { tokenHash }, data: { revokedAt: new Date() } });
  }
}
```

- [ ] **Step 4: Implementasi `backend/src/modules/auth/auth.service.ts`** (register saja dulu; login & refresh ditambah di Task 9)

```ts
import { createHash } from 'crypto';
import { AuthRepository } from './auth.repository';
import { hashPassword, verifyPassword } from '../../shared/hash';
import { signAccess, signRefresh, verifyRefresh } from '../../shared/jwt';
import { Errors } from '../../shared/errors';
import type { AppConfig } from '../../shared/config';

const sha = (s: string) => createHash('sha256').update(s).digest('hex');

export class AuthService {
  constructor(private repo: AuthRepository, private cfg: AppConfig) {}

  async register(input: { nama: string; nomorTelepon: string; password: string }) {
    if (await this.repo.findByPhone(input.nomorTelepon)) {
      throw Errors.conflict('Nomor telepon sudah terdaftar.');
    }
    const passwordHash = await hashPassword(input.password);
    const user = await this.repo.createUser({
      nama: input.nama, nomorTelepon: input.nomorTelepon, passwordHash,
    });
    return { id: user.id, nama: user.nama };
  }
}
```

- [ ] **Step 5: Jalankan test** — Run: `npm test -- auth.service` — Expected: PASS (2 test).

- [ ] **Step 6: Commit**

```bash
git add backend/src/modules/auth backend/test/auth.service.test.ts
git commit -m "feat(auth): repository + service register (TDD)"
```

---

## Task 9: Auth — login + refresh rotation (service)

**Files:**
- Modify: `backend/src/modules/auth/auth.service.ts` (tambah `login`, `refresh`, `logout`)
- Test: `backend/test/auth.login.test.ts`

- [ ] **Step 1: Tulis test gagal `backend/test/auth.login.test.ts`**

```ts
import { describe, it, expect, vi } from 'vitest';
import { AuthService } from '../src/modules/auth/auth.service';
import { hashPassword } from '../src/shared/hash';

const cfg = { jwtAccessSecret: 'a'.repeat(32), jwtRefreshSecret: 'b'.repeat(32),
  accessTokenTtl: 900, refreshTokenTtl: 604800 } as any;

describe('AuthService.login', () => {
  it('mengembalikan access+refresh token saat kredensial benar', async () => {
    const passwordHash = await hashPassword('rahasia123');
    const repo: any = {
      findByPhone: vi.fn().mockResolvedValue({ id: 'u1', nama: 'A', passwordHash, status: 'ACTIVE' }),
      getPermissions: vi.fn().mockResolvedValue(['anggota:read']),
      saveRefreshToken: vi.fn().mockResolvedValue({}),
    };
    const svc = new AuthService(repo, cfg);
    const out = await svc.login({ nomorTelepon: '0811', password: 'rahasia123' });
    expect(out.accessToken).toBeTruthy();
    expect(out.refreshToken).toBeTruthy();
    expect(repo.saveRefreshToken).toHaveBeenCalled();
  });
  it('menolak password salah', async () => {
    const passwordHash = await hashPassword('rahasia123');
    const repo: any = { findByPhone: vi.fn().mockResolvedValue({ id: 'u1', passwordHash, status: 'ACTIVE' }) };
    const svc = new AuthService(repo, cfg);
    await expect(svc.login({ nomorTelepon: '0811', password: 'salah' })).rejects.toThrow();
  });
});
```

- [ ] **Step 2: Jalankan test** — Run: `npm test -- auth.login` — Expected: FAIL.

- [ ] **Step 3: Tambah method ke `auth.service.ts`** (sisipkan di dalam class `AuthService`)

```ts
  async login(input: { nomorTelepon: string; password: string }) {
    const user = await this.repo.findByPhone(input.nomorTelepon);
    if (!user || user.status !== 'ACTIVE') throw Errors.unauthorized('Kredensial salah.');
    if (!(await verifyPassword(user.passwordHash, input.password))) {
      throw Errors.unauthorized('Kredensial salah.');
    }
    return this.issueTokens(user.id);
  }

  async refresh(refreshToken: string) {
    const { sub } = verifyRefresh(this.cfg, refreshToken); // throw bila invalid
    const stored = await this.repo.findRefreshToken(sha(refreshToken));
    if (!stored || stored.revokedAt) throw Errors.unauthorized('Sesi tidak valid.');
    await this.repo.revokeRefreshToken(sha(refreshToken)); // rotasi: token lama dicabut
    return this.issueTokens(sub);
  }

  async logout(refreshToken: string) {
    const stored = await this.repo.findRefreshToken(sha(refreshToken));
    if (stored && !stored.revokedAt) await this.repo.revokeRefreshToken(sha(refreshToken));
  }

  private async issueTokens(userId: string) {
    const permissions = await this.repo.getPermissions(userId);
    const accessToken = signAccess(this.cfg, { sub: userId, permissions });
    const refreshToken = signRefresh(this.cfg, userId);
    const expiresAt = new Date(Date.now() + this.cfg.refreshTokenTtl * 1000);
    await this.repo.saveRefreshToken(userId, sha(refreshToken), expiresAt);
    return { accessToken, refreshToken, permissions };
  }
```

- [ ] **Step 4: Jalankan test** — Run: `npm test -- auth.login` — Expected: PASS (2 test).

- [ ] **Step 5: Commit**

```bash
git add backend/src/modules/auth/auth.service.ts backend/test/auth.login.test.ts
git commit -m "feat(auth): login + refresh rotation + logout (TDD)"
```

---

## Task 10: Auth — controller, routes, cookie + middleware autentikasi

**Files:**
- Create: `backend/src/modules/auth/auth.controller.ts`
- Create: `backend/src/modules/auth/auth.routes.ts`
- Create: `backend/src/shared/authenticate.ts`
- Modify: `backend/src/app.ts` (pasang router + error middleware)
- Test: `backend/test/auth.e2e.test.ts`

- [ ] **Step 1: Implementasi `backend/src/shared/authenticate.ts`** (parse access token dari header Authorization)

```ts
import { RequestHandler } from 'express';
import { verifyAccess } from './jwt';
import { Errors } from './errors';
import type { AppConfig } from './config';

export function authenticate(cfg: AppConfig): RequestHandler {
  return (req, _res, next) => {
    const header = req.headers.authorization;
    if (!header?.startsWith('Bearer ')) return next(Errors.unauthorized());
    try {
      req.user = verifyAccess(cfg, header.slice(7));
      next();
    } catch { next(Errors.unauthorized('Token tidak valid/kedaluwarsa.')); }
  };
}
```

- [ ] **Step 2: Implementasi `backend/src/modules/auth/auth.controller.ts`**

```ts
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AuthService } from './auth.service';
import type { AppConfig } from '../../shared/config';

const registerSchema = z.object({
  nama: z.string().min(2), nomorTelepon: z.string().min(8), password: z.string().min(8),
});
const loginSchema = z.object({ nomorTelepon: z.string().min(8), password: z.string().min(1) });

export class AuthController {
  constructor(private svc: AuthService, private cfg: AppConfig) {}

  private setRefreshCookie(res: Response, token: string) {
    res.cookie('kopdes_rt', token, {
      httpOnly: true, secure: this.cfg.nodeEnv === 'production', sameSite: 'lax',
      domain: this.cfg.cookieDomain, path: '/api/v1/auth', maxAge: this.cfg.refreshTokenTtl * 1000,
    });
  }

  register = async (req: Request, res: Response, next: NextFunction) => {
    try { res.status(201).json({ data: await this.svc.register(registerSchema.parse(req.body)) }); }
    catch (e) { next(e); }
  };

  login = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { accessToken, refreshToken, permissions } = await this.svc.login(loginSchema.parse(req.body));
      this.setRefreshCookie(res, refreshToken);
      res.json({ data: { accessToken, permissions } });
    } catch (e) { next(e); }
  };

  refresh = async (req: Request, res: Response, next: NextFunction) => {
    try {
      const rt = req.cookies?.kopdes_rt;
      const { accessToken, refreshToken, permissions } = await this.svc.refresh(rt);
      this.setRefreshCookie(res, refreshToken);
      res.json({ data: { accessToken, permissions } });
    } catch (e) { next(e); }
  };

  logout = async (req: Request, res: Response, next: NextFunction) => {
    try {
      if (req.cookies?.kopdes_rt) await this.svc.logout(req.cookies.kopdes_rt);
      res.clearCookie('kopdes_rt', { path: '/api/v1/auth' });
      res.json({ data: { ok: true } });
    } catch (e) { next(e); }
  };
}
```

- [ ] **Step 3: Implementasi `backend/src/modules/auth/auth.routes.ts`**

```ts
import { Router } from 'express';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthRepository } from './auth.repository';
import type { AppConfig } from '../../shared/config';

export function authRoutes(cfg: AppConfig): Router {
  const ctrl = new AuthController(new AuthService(new AuthRepository(), cfg), cfg);
  const r = Router();
  r.post('/register', ctrl.register);
  r.post('/login', ctrl.login);
  r.post('/refresh', ctrl.refresh);
  r.post('/logout', ctrl.logout);
  return r;
}
```

- [ ] **Step 4: Modifikasi `backend/src/app.ts`** — terima config, pasang router + error middleware (di akhir)

```ts
import express, { Express } from 'express';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { loadConfig, AppConfig } from './shared/config';
import { errorMiddleware } from './shared/error-middleware';
import { authRoutes } from './modules/auth/auth.routes';

export function createApp(cfg: AppConfig = loadConfig()): Express {
  const app = express();
  app.use(helmet());
  app.use(cors({ origin: cfg.corsOrigin?.split(',') ?? true, credentials: true }));
  app.use(express.json());
  app.use(cookieParser());

  app.get('/api/v1/health', (_req, res) => res.json({ data: { status: 'ok' } }));
  app.use('/api/v1/auth', authRoutes(cfg));

  app.use(errorMiddleware); // WAJIB paling akhir
  return app;
}
```
> `main.ts` tetap memanggil `createApp()` tanpa argumen (config dimuat dari env).

- [ ] **Step 5: Tulis test integrasi `backend/test/auth.e2e.test.ts`** (butuh DB test — set `DATABASE_URL` ke `..._kopdes_test`)

```ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../src/app';
import { loadConfig } from '../src/shared/config';
import { prisma } from '../src/shared/prisma';

const cfg = loadConfig();
const app = createApp(cfg);
const phone = '0811' + Date.now();

beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => {
  await prisma.user.deleteMany({ where: { nomorTelepon: phone } });
  await prisma.$disconnect();
});

describe('Auth E2E', () => {
  it('register → login → refresh', async () => {
    const reg = await request(app).post('/api/v1/auth/register')
      .send({ nama: 'Uji', nomorTelepon: phone, password: 'rahasia123' });
    expect(reg.status).toBe(201);

    const login = await request(app).post('/api/v1/auth/login')
      .send({ nomorTelepon: phone, password: 'rahasia123' });
    expect(login.status).toBe(200);
    expect(login.body.data.accessToken).toBeTruthy();
    const cookie = login.headers['set-cookie'];
    expect(cookie).toBeTruthy();

    const refresh = await request(app).post('/api/v1/auth/refresh').set('Cookie', cookie);
    expect(refresh.status).toBe(200);
    expect(refresh.body.data.accessToken).toBeTruthy();
  });
});
```

- [ ] **Step 6: Jalankan test** (pastikan `.env` test menunjuk DB test, atau set inline)

Run:
```bash
DATABASE_URL="postgresql://USER:PASS@127.0.0.1:5432/developerkdmpmy_kopdes_test?schema=public" npx prisma migrate deploy
npm test -- auth.e2e
```
Expected: PASS (register 201, login token, refresh token).

- [ ] **Step 7: Commit**

```bash
git add backend/src/modules/auth backend/src/shared/authenticate.ts backend/src/app.ts backend/test/auth.e2e.test.ts
git commit -m "feat(auth): controller/routes/cookies + authenticate middleware + e2e"
```

---

## Task 11: Seed — permission, role inti, admin pertama, settings

**Files:**
- Create: `backend/prisma/seed.ts`
- Test: `backend/test/seed.test.ts`

- [ ] **Step 1: Implementasi `backend/prisma/seed.ts`**

```ts
import { PrismaClient } from '@prisma/client';
import argon2 from 'argon2';
import { randomBytes } from 'crypto';

const prisma = new PrismaClient();

const PERMISSIONS = [
  ['anggota:read', 'anggota'], ['anggota:create', 'anggota'], ['anggota:update', 'anggota'],
  ['user:manage', 'user'], ['role:manage', 'user'],
  ['simpanan:create', 'simpanan'], ['pinjaman:approve', 'pinjaman'],
  ['laporan:read', 'laporan'], ['cms:publish', 'cms'], ['setting:manage', 'setting'],
];

export async function seed() {
  for (const [kode, modul] of PERMISSIONS) {
    await prisma.permission.upsert({ where: { kode }, update: {}, create: { kode, modul } });
  }
  const allPerms = await prisma.permission.findMany();

  const superAdmin = await prisma.role.upsert({
    where: { nama: 'Super Admin' }, update: {},
    create: { nama: 'Super Admin', isSystem: true, deskripsi: 'Akses penuh' },
  });
  await prisma.rolePermission.deleteMany({ where: { roleId: superAdmin.id } });
  await prisma.rolePermission.createMany({
    data: allPerms.map(p => ({ roleId: superAdmin.id, permissionId: p.id })), skipDuplicates: true,
  });

  await prisma.role.upsert({ where: { nama: 'Anggota' }, update: {},
    create: { nama: 'Anggota', isSystem: true, deskripsi: 'Anggota koperasi' } });

  // admin pertama
  const phone = process.env.SEED_ADMIN_PHONE ?? '081200000000';
  const pwd = process.env.SEED_ADMIN_PASSWORD ?? 'admin12345';
  const admin = await prisma.user.upsert({
    where: { nomorTelepon: phone }, update: {},
    create: { nama: 'Administrator', nomorTelepon: phone, passwordHash: await argon2.hash(pwd) },
  });
  await prisma.userRole.upsert({
    where: { userId_roleId: { userId: admin.id, roleId: superAdmin.id } },
    update: {}, create: { userId: admin.id, roleId: superAdmin.id },
  });

  // settings inti — termasuk URL admin acak (anti bruteforce /admin)
  const defaults: [string, unknown][] = [
    ['admin_path_slug', randomBytes(6).toString('hex')],
    ['nama_koperasi', 'Koperasi Desa Merah Putih'],
    ['simpanan_pokok_nominal', 100000],
    ['simpanan_wajib_nominal', 25000],
    ['anggota_diskon_persen', 0],
  ];
  for (const [key, value] of defaults) {
    await prisma.setting.upsert({ where: { key }, update: {}, create: { key, value: value as object } });
  }
  return { adminPhone: phone };
}

if (require.main === module) {
  seed().then(r => { console.log('Seed selesai. Admin:', r.adminPhone); process.exit(0); })
        .catch(e => { console.error(e); process.exit(1); });
}
```

- [ ] **Step 2: Tulis test `backend/test/seed.test.ts`** (jalan di DB test)

```ts
import { describe, it, expect, afterAll } from 'vitest';
import { seed } from '../prisma/seed';
import { prisma } from '../src/shared/prisma';

afterAll(async () => { await prisma.$disconnect(); });

describe('seed', () => {
  it('membuat permission, role Super Admin, dan admin', async () => {
    await seed();
    const perm = await prisma.permission.findUnique({ where: { kode: 'user:manage' } });
    expect(perm).toBeTruthy();
    const role = await prisma.role.findUnique({ where: { nama: 'Super Admin' } });
    expect(role?.isSystem).toBe(true);
    const slug = await prisma.setting.findUnique({ where: { key: 'admin_path_slug' } });
    expect(slug?.value).toBeTruthy();
  });
});
```

- [ ] **Step 3: Jalankan seed di DB dev + test**

Run:
```bash
npx prisma db seed 2>/dev/null || npx ts-node prisma/seed.ts
npm test -- seed
```
Expected: seed sukses, test PASS.

- [ ] **Step 4: Tambah konfigurasi prisma seed ke `package.json`** (blok baru di root JSON)

```json
"prisma": { "seed": "ts-node prisma/seed.ts" }
```

- [ ] **Step 5: Commit**

```bash
git add backend/prisma/seed.ts backend/test/seed.test.ts backend/package.json
git commit -m "feat(backend): seed permission/role/admin/settings"
```

---

## Task 12: Modul Keanggotaan (repository → service → controller → routes)

**Files:**
- Create: `backend/src/modules/anggota/anggota.repository.ts`
- Create: `backend/src/modules/anggota/anggota.service.ts`
- Create: `backend/src/modules/anggota/anggota.controller.ts`
- Create: `backend/src/modules/anggota/anggota.routes.ts`
- Modify: `backend/src/app.ts` (pasang router anggota dengan authenticate + requirePermission)
- Test: `backend/test/anggota.service.test.ts`

- [ ] **Step 1: Tulis test gagal `backend/test/anggota.service.test.ts`**

```ts
import { describe, it, expect, vi } from 'vitest';
import { AnggotaService } from '../src/modules/anggota/anggota.service';

describe('AnggotaService.create', () => {
  it('membuat anggota dengan nomor anggota otomatis', async () => {
    const repo: any = {
      countMembers: vi.fn().mockResolvedValue(41),
      createMember: vi.fn().mockImplementation(async (d: any) => ({ id: 'm1', ...d })),
    };
    const svc = new AnggotaService(repo);
    const out = await svc.create({ userId: 'u1', nama: 'Budi' });
    expect(out.nomorAnggota).toMatch(/^AGT-/);
    expect(repo.createMember).toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Jalankan test** — Run: `npm test -- anggota.service` — Expected: FAIL.

- [ ] **Step 3: Implementasi `backend/src/modules/anggota/anggota.repository.ts`**

```ts
import { prisma } from '../../shared/prisma';

export class AnggotaRepository {
  countMembers() { return prisma.member.count(); }
  createMember(data: { userId: string; nomorAnggota: string; nik?: string; alamat?: string }) {
    return prisma.member.create({ data });
  }
  list(skip = 0, take = 20) {
    return prisma.member.findMany({ skip, take, include: { user: { select: { nama: true, nomorTelepon: true } } } });
  }
  findById(id: string) { return prisma.member.findUnique({ where: { id } }); }
}
```

- [ ] **Step 4: Implementasi `backend/src/modules/anggota/anggota.service.ts`**

```ts
import { AnggotaRepository } from './anggota.repository';

export class AnggotaService {
  constructor(private repo: AnggotaRepository) {}

  async create(input: { userId: string; nama: string; nik?: string; alamat?: string }) {
    const seq = (await this.repo.countMembers()) + 1;
    const nomorAnggota = `AGT-${String(seq).padStart(6, '0')}`;
    return this.repo.createMember({
      userId: input.userId, nomorAnggota, nik: input.nik, alamat: input.alamat,
    });
  }
  list(page = 1, size = 20) { return this.repo.list((page - 1) * size, size); }
  get(id: string) { return this.repo.findById(id); }
}
```

- [ ] **Step 5: Jalankan test** — Run: `npm test -- anggota.service` — Expected: PASS.

- [ ] **Step 6: Implementasi `backend/src/modules/anggota/anggota.controller.ts`**

```ts
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AnggotaService } from './anggota.service';

const createSchema = z.object({
  userId: z.string().uuid(), nama: z.string().min(2),
  nik: z.string().optional(), alamat: z.string().optional(),
});

export class AnggotaController {
  constructor(private svc: AnggotaService) {}
  create = async (req: Request, res: Response, next: NextFunction) => {
    try { res.status(201).json({ data: await this.svc.create(createSchema.parse(req.body)) }); }
    catch (e) { next(e); }
  };
  list = async (req: Request, res: Response, next: NextFunction) => {
    try { res.json({ data: await this.svc.list(Number(req.query.page ?? 1)) }); }
    catch (e) { next(e); }
  };
}
```

- [ ] **Step 7: Implementasi `backend/src/modules/anggota/anggota.routes.ts`**

```ts
import { Router } from 'express';
import { AnggotaController } from './anggota.controller';
import { AnggotaService } from './anggota.service';
import { AnggotaRepository } from './anggota.repository';
import { authenticate } from '../../shared/authenticate';
import { requirePermission } from '../../shared/rbac';
import type { AppConfig } from '../../shared/config';

export function anggotaRoutes(cfg: AppConfig): Router {
  const ctrl = new AnggotaController(new AnggotaService(new AnggotaRepository()));
  const r = Router();
  r.use(authenticate(cfg));
  r.get('/', requirePermission('anggota:read'), ctrl.list);
  r.post('/', requirePermission('anggota:create'), ctrl.create);
  return r;
}
```

- [ ] **Step 8: Modifikasi `backend/src/app.ts`** — tambah baris router anggota (sebelum `errorMiddleware`)

```ts
import { anggotaRoutes } from './modules/anggota/anggota.routes';
// ...di dalam createApp, setelah authRoutes:
  app.use('/api/v1/anggota', anggotaRoutes(cfg));
```

- [ ] **Step 9: Jalankan seluruh test**

Run: `npm test`
Expected: SEMUA test PASS (health, config, errors, hash, jwt, rbac, auth.service, auth.login, auth.e2e, seed, anggota.service).

- [ ] **Step 10: Commit**

```bash
git add backend/src/modules/anggota backend/src/app.ts backend/test/anggota.service.test.ts
git commit -m "feat(anggota): modul keanggotaan (repo/service/controller/routes) + rbac"
```

---

## Task 13: Deploy config — PM2 + .htaccess

**Files:**
- Create: `ecosystem.config.cjs` (root project)
- Modify: `.htaccess` (root docroot — tambah proxy, pertahankan blok cPanel PHP)

- [ ] **Step 1: Build backend**

Run:
```bash
cd /home/developerkdmpmy/kopdes.developerkdmp.my.id/backend
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh" && npm run build
```
Expected: `dist/main.js` terbuat.

- [ ] **Step 2: Buat `ecosystem.config.cjs`** di root project

```js
module.exports = {
  apps: [
    {
      name: 'kopdes-backend', cwd: './backend', script: 'dist/main.js',
      env: { NODE_ENV: 'production', PORT: 4010, HOST: '127.0.0.1' },
      error_file: '/home/developerkdmpmy/logs/kopdes-backend.err.log',
      out_file: '/home/developerkdmpmy/logs/kopdes-backend.out.log',
    },
  ],
};
```
> `kopdes-frontend` & `kopdes-worker` ditambah di plan 1C/1B.

- [ ] **Step 3: Tambah proxy ke `.htaccess`** (TAMBAHKAN di atas blok cPanel PHP, jangan hapus blok itu)

```apache
RewriteEngine On
RewriteRule ^api/(.*)$ http://127.0.0.1:4010/api/$1 [P,L]
```

- [ ] **Step 4: Start PM2 + verifikasi**

Run:
```bash
cd /home/developerkdmpmy/kopdes.developerkdmp.my.id
pm2 start ecosystem.config.cjs && sleep 2 && curl -s http://127.0.0.1:4010/api/v1/health
```
Expected: `{"data":{"status":"ok"}}`

- [ ] **Step 5: Verifikasi lewat domain (proxy Apache)**

Run: `curl -s https://kopdes.developerkdmp.my.id/api/v1/health`
Expected: `{"data":{"status":"ok"}}`

- [ ] **Step 6: Commit + simpan PM2**

```bash
git add ecosystem.config.cjs .htaccess
git commit -m "chore(deploy): pm2 ecosystem + apache proxy /api"
pm2 save
```

---

## Task 14: Scan keamanan pra-deploy

- [ ] **Step 1: Jalankan predeploy-scan**

Run: `predeploy-scan /home/developerkdmpmy/kopdes.developerkdmp.my.id/backend`
Expected: exit 0 (tidak ada secret ter-commit, tidak ada finding kritis). Jika ada finding, perbaiki sebelum lanjut.

- [ ] **Step 2: Commit perbaikan bila ada**

```bash
git add -A && git commit -m "fix(security): perbaikan temuan predeploy-scan"
```

---

## Definition of Done (Fase 1A)

- [ ] `npm test` hijau semua (unit + integrasi).
- [ ] `curl https://kopdes.developerkdmp.my.id/api/v1/health` → `{"data":{"status":"ok"}}`.
- [ ] Bisa register → login (dapat accessToken + cookie refresh HttpOnly) → refresh → logout.
- [ ] Endpoint `/api/v1/anggota` menolak tanpa token (401) & tanpa izin (403); lolos dengan token Super Admin.
- [ ] Seed menghasilkan Super Admin, role Anggota, dan `admin_path_slug` acak di settings.
- [ ] `predeploy-scan` exit 0.

---

## Catatan untuk Plan 1B (Core Finansial — berikutnya)

1B akan menambah ke `schema.prisma`: `LedgerAccount`, `Account`, `Transaction`, `JournalEntry`, `JournalLine`, `IdempotencyKey`, `Loan`, `LoanSchedule` (lihat §7 spec), lalu membangun `SavingsService.setorSimpanan` (spec §9) dengan `SERIALIZABLE` + `pg_advisory_xact_lock` + idempotency, dan worker pg-boss. Pondasi auth/RBAC/anggota dari 1A jadi prasyaratnya.
```
