# Production Readiness 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:** Harden boha-app-ts for production: security fixes, Zod validation, service layer extraction, test suite, and build preparation.

**Architecture:** Fastify backend with Prisma ORM (MySQL), React SPA frontend, JWT + TOTP auth. Routes are refactored from fat controllers to thin handlers calling service functions. All request bodies validated with Zod schemas. Tests target the HTTP API layer via Supertest.

**Tech Stack:** TypeScript, Fastify 5, Prisma 6, Zod, Vitest, Supertest, bcryptjs, jsonwebtoken, otpauth

**Spec:** `docs/superpowers/specs/2026-03-23-production-readiness-design.md`

---

## Phase 1: Security Hardening

### Task 1: Secrets & Environment

**Files:**
- Verify: `.gitignore`
- Create: `.env.example`

- [ ] **Step 1: Verify .env is gitignored and never committed**

```bash
grep "^\.env$" .gitignore
git log --all --diff-filter=A -- .env
```

Expected: `.env` appears in `.gitignore`. Git log shows no results (never committed).
If `.env` was committed, run `git rm --cached .env` and add to `.gitignore`.

- [ ] **Step 2: Create .env.example**

Create `.env.example` with placeholder values:

```env
# Database
DATABASE_URL=mysql://user:password@localhost:3306/app

# Server
PORT=3001
HOST=127.0.0.1
APP_ENV=local

# Auth — MUST regenerate for production: openssl rand -hex 32
JWT_SECRET=generate-with-openssl-rand-hex-32
ACCESS_TOKEN_EXPIRY=900
REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000

# TOTP — MUST regenerate for production: openssl rand -hex 32
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32

# File storage
NAS_PATH=Z:/02_PROJEKTY
MAX_UPLOAD_SIZE=52428800

# Email
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=

# CORS (production only, comma-separated)
CORS_ORIGINS=
```

- [ ] **Step 3: Commit**

```bash
git add .env.example .gitignore
git commit -m "chore: add .env.example with placeholder values"
```

---

### Task 2: Rate Limiting — Login Endpoint

**Files:**
- Modify: `src/server.ts`
- Modify: `src/routes/admin/auth.ts`

- [ ] **Step 1: Add per-route rate limit to login endpoint**

In `src/routes/admin/auth.ts`, add rate limit config to the login route:

```typescript
fastify.post('/login', {
  config: {
    rateLimit: {
      max: 20,
      timeWindow: '1 minute',
    },
  },
}, async (request, reply) => {
  // ... existing login handler
});
```

- [ ] **Step 2: Verify global rate limit stays at 100/min**

Confirm `src/server.ts` line 53-56 remains:
```typescript
await app.register(rateLimit, {
  max: 100,
  timeWindow: '1 minute',
});
```

- [ ] **Step 3: Commit**

```bash
git add src/routes/admin/auth.ts
git commit -m "security: add stricter rate limit on login endpoint (20/min)"
```

---

### Task 3: Security Headers — CSP & Permissions-Policy

**Files:**
- Modify: `src/middleware/security.ts`

- [ ] **Step 1: Add CSP (production only) and Permissions-Policy**

Replace `src/middleware/security.ts` with:

```typescript
import { FastifyReply, FastifyRequest } from 'fastify';
import { config } from '../config/env';

export async function securityHeaders(
  _request: FastifyRequest,
  reply: FastifyReply,
): Promise<void> {
  reply.header('X-Content-Type-Options', 'nosniff');
  reply.header('X-Frame-Options', 'DENY');
  reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
  reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');

  if (config.isProduction) {
    reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
    reply.header(
      'Content-Security-Policy',
      "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'",
    );
  }
}
```

- [ ] **Step 2: Commit**

```bash
git add src/middleware/security.ts
git commit -m "security: add CSP (prod) and Permissions-Policy headers"
```

---

### Task 4: Request Body Limits

**Files:**
- Modify: `src/server.ts`

- [ ] **Step 1: Add global body limit to Fastify**

In `src/server.ts`, add `bodyLimit` to the Fastify constructor:

```typescript
const app = Fastify({
  logger: {
    level: config.isProduction ? 'warn' : 'info',
  },
  trustProxy: true,
  bodyLimit: 1048576, // 1MB global limit
});
```

- [ ] **Step 2: Add 10KB limit to auth routes**

In `src/routes/admin/auth.ts`, add bodyLimit to login, refresh, and TOTP routes:

```typescript
fastify.post('/login', {
  bodyLimit: 10240, // 10KB
  config: {
    rateLimit: { max: 20, timeWindow: '1 minute' },
  },
}, async (request, reply) => { /* ... */ });

fastify.post('/refresh', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ });
```

Also in `src/routes/admin/totp.ts`, add to verify endpoints:

```typescript
fastify.post('/verify', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ });
fastify.post('/login/totp', { bodyLimit: 10240 }, async (request, reply) => { /* ... */ });
```

- [ ] **Step 3: Commit**

```bash
git add src/server.ts src/routes/admin/auth.ts src/routes/admin/totp.ts
git commit -m "security: add request body size limits (1MB global, 10KB auth)"
```

---

### Task 5: Timing-Safe Auth

**Files:**
- Modify: `src/services/auth.ts`

- [ ] **Step 1: Add dummy hash constant and timing-safe comparison**

At the top of `src/services/auth.ts`, add a pre-computed dummy hash:

```typescript
// Pre-computed bcrypt hash for timing-safe comparison when user not found
const DUMMY_HASH = '$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO';
```

Then in the `login()` function, replace the early return on user-not-found:

```typescript
if (!user) {
  // Timing-safe: run bcrypt even when user not found
  await bcrypt.compare(password, DUMMY_HASH);
  return { type: 'error', message: 'Neplatné přihlašovací údaje', status: 401 };
}
```

- [ ] **Step 2: Commit**

```bash
git add src/services/auth.ts
git commit -m "security: timing-safe auth to prevent username enumeration"
```

---

## Phase 2: Input Validation (Zod)

### Task 6: Install Zod, Create Common Helper & Auth Schema

**Files:**
- Create: `src/schemas/common.ts`
- Create: `src/schemas/auth.schema.ts`
- Modify: `src/routes/admin/auth.ts`

- [ ] **Step 1: Install zod**

```bash
npm install zod
```

- [ ] **Step 2: Create common validation helper**

Create `src/schemas/common.ts`:

```typescript
import { ZodSchema, ZodError } from 'zod';

export function parseBody<T>(schema: ZodSchema<T>, body: unknown): { data: T } | { error: string } {
  try {
    return { data: schema.parse(body) };
  } catch (e) {
    if (e instanceof ZodError) {
      return { error: e.errors.map(err => err.message).join(', ') };
    }
    return { error: 'Neplatný požadavek' };
  }
}
```

- [ ] **Step 3: Create auth validation schemas**

Create `src/schemas/auth.schema.ts`:

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

export const LoginSchema = z.object({
  username: z.string().min(1, 'Uživatelské jméno je povinné'),
  password: z.string().min(1, 'Heslo je povinné'),
  remember_me: z.boolean().optional().default(false),
});

export const TotpVerifySchema = z.object({
  login_token: z.string().min(1, 'Token je povinný'),
  totp_code: z.string().length(6, 'Kód musí mít 6 číslic'),
});

export const TotpBackupSchema = z.object({
  login_token: z.string().min(1, 'Token je povinný'),
  backup_code: z.string().min(1, 'Záložní kód je povinný'),
});

export type LoginInput = z.infer<typeof LoginSchema>;
export type TotpVerifyInput = z.infer<typeof TotpVerifySchema>;
export type TotpBackupInput = z.infer<typeof TotpBackupSchema>;
```

- [ ] **Step 4: Apply schemas in auth routes**

In `src/routes/admin/auth.ts`, import and use:

```typescript
import { parseBody } from '../../schemas/common';
import { LoginSchema, TotpVerifySchema } from '../../schemas/auth.schema';
```

Replace existing manual validation in the login handler with:

```typescript
const parsed = parseBody(LoginSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400);
const { username, password, remember_me } = parsed.data;
```

- [ ] **Step 5: Commit**

```bash
git add src/schemas/common.ts src/schemas/auth.schema.ts src/routes/admin/auth.ts package.json package-lock.json
git commit -m "feat: add Zod validation for auth endpoints"
```

---

### Task 7: Create Validation Schemas for All Domains

**Files:**
- Create: `src/schemas/users.schema.ts`
- Create: `src/schemas/offers.schema.ts`
- Create: `src/schemas/orders.schema.ts`
- Create: `src/schemas/invoices.schema.ts`
- Create: `src/schemas/projects.schema.ts`
- Create: `src/schemas/customers.schema.ts`
- Create: `src/schemas/attendance.schema.ts`
- Create: `src/schemas/trips.schema.ts`
- Create: `src/schemas/vehicles.schema.ts`
- Create: `src/schemas/leave-requests.schema.ts`
- Create: `src/schemas/roles.schema.ts`
- Create: `src/schemas/bank-accounts.schema.ts`
- Create: `src/schemas/company-settings.schema.ts`
- Create: `src/schemas/received-invoices.schema.ts`
- Create: `src/schemas/scope-templates.schema.ts`
- Create: `src/schemas/profile.schema.ts`

- [ ] **Step 1: Create domain schemas**

Create each schema file based on the existing `request.body as Record<string, unknown>` patterns in each route file. Example for `src/schemas/users.schema.ts`:

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

export const CreateUserSchema = z.object({
  username: z.string().min(1, 'Uživatelské jméno je povinné'),
  email: z.string().email('Neplatný formát e-mailu'),
  password: z.string().min(8, 'Heslo musí mít alespoň 8 znaků'),
  first_name: z.string().optional().default(''),
  last_name: z.string().optional().default(''),
  role_id: z.number().int().positive().optional(),
  is_active: z.boolean().optional().default(true),
});

export const UpdateUserSchema = CreateUserSchema.partial().omit({ password: true }).extend({
  password: z.string().min(8, 'Heslo musí mít alespoň 8 znaků').optional(),
});

export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
```

Follow the same pattern for all domains — each schema mirrors the fields currently extracted from `request.body`. Cover ALL route files that use `as Record<string, unknown>`: users, offers, orders, invoices, projects, customers, attendance, trips, vehicles, leave-requests, roles, bank-accounts, company-settings, received-invoices, scope-templates, profile, totp.

- [ ] **Step 2: Apply schemas in all route files**

For each route file, replace `const body = request.body as Record<string, unknown>` with:

```typescript
const parsed = parseBody(CreateOfferSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
```

- [ ] **Step 3: Remove `as Record<string, unknown>` casts**

Search all route files for remaining `as Record<string, unknown>` and replace with typed Zod parsing.

```bash
grep -rn "as Record<string, unknown>" src/routes/
```

Expected: zero results after cleanup.

- [ ] **Step 4: Commit**

```bash
git add src/schemas/ src/routes/
git commit -m "feat: add Zod validation schemas for all domain routes"
```

---

## Phase 3: Service Layer Refactor

### Task 8: Create Numbering Service

**Files:**
- Create: `src/services/numbering.service.ts`
- Modify: `src/routes/admin/orders.ts`
- Modify: `src/routes/admin/projects.ts`
- Modify: `src/routes/admin/quotations.ts`

- [ ] **Step 1: Create numbering service**

Create `src/services/numbering.service.ts`:

```typescript
import prisma from '../config/database';

/**
 * Shared number generator for orders and projects.
 * Format: YYtypeCode + 4-digit sequence (e.g., 26710003)
 * Queries MAX from both orders and projects tables.
 */
export async function generateSharedNumber(): Promise<string> {
  const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } });
  const typeCode = settings?.order_type_code || '71';
  const yy = String(new Date().getFullYear()).slice(-2);
  const prefix = `${yy}${typeCode}`;
  const prefixLen = prefix.length;
  const likePattern = `${prefix}%`;

  const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
    SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
      SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
      FROM orders WHERE order_number LIKE ${likePattern}
      UNION ALL
      SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
      FROM projects WHERE project_number LIKE ${likePattern}
    ) combined
  `;
  const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
  return `${prefix}${String(nextNum).padStart(4, '0')}`;
}

/**
 * Next offer number. Queries MAX from quotations table.
 * Format: YEAR/PREFIX/NNN (e.g., 2026/NA/008)
 */
export async function generateOfferNumber(): Promise<string> {
  const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } });
  const prefix = settings?.quotation_prefix || 'NA';
  const year = new Date().getFullYear();
  const likePattern = `${year}/${prefix}/%`;

  const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>`
    SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num
    FROM quotations
    WHERE quotation_number LIKE ${likePattern}
  `;
  const nextNum = Number(result[0]?.max_num ?? 0) + 1;
  return `${year}/${prefix}/${String(nextNum).padStart(3, '0')}`;
}

/**
 * Next invoice number via atomic sequence table.
 */
export async function generateInvoiceNumber(year: number): Promise<number> {
  return prisma.$transaction(async (tx) => {
    const existing = await tx.number_sequences.findFirst({
      where: { type: 'invoice', year },
    });

    if (existing) {
      const nextNum = (existing.last_number ?? 0) + 1;
      await tx.number_sequences.update({
        where: { id: existing.id },
        data: { last_number: nextNum },
      });
      return nextNum;
    }

    await tx.number_sequences.create({
      data: { type: 'invoice', year, last_number: 1 },
    });
    return 1;
  });
}
```

- [ ] **Step 2: Update orders.ts to use numbering service**

Replace `generateOrderNumber()` and `generateProjectNumber()` in `src/routes/admin/orders.ts` with imports from the service:

```typescript
import { generateSharedNumber } from '../../services/numbering.service';
```

Remove the local `generateOrderNumber()`, `generateProjectNumber()`, and `generateSharedNumber()` functions. Replace all calls:

```typescript
const orderNumber = await generateSharedNumber();
const projectNumber = await generateSharedNumber();
```

- [ ] **Step 3: Update projects.ts and quotations.ts similarly**

In `src/routes/admin/projects.ts`, replace the inline next-number logic with:

```typescript
import { generateSharedNumber } from '../../services/numbering.service';
```

In `src/routes/admin/quotations.ts`, replace the inline offer number logic with:

```typescript
import { generateOfferNumber } from '../../services/numbering.service';
```

- [ ] **Step 4: Update sequence.ts to re-export from numbering service**

In `src/utils/sequence.ts`, replace the implementation with a re-export to avoid breaking any remaining callers:

```typescript
// Re-export from canonical location
export { generateInvoiceNumber as getNextNumber } from '../services/numbering.service';
```

Check for any remaining imports of `sequence.ts` and update them to import from `numbering.service.ts` directly:

```bash
grep -rn "from.*sequence" src/
```

If no callers remain, delete `src/utils/sequence.ts`.

- [ ] **Step 5: Commit**

```bash
git add src/services/numbering.service.ts src/routes/admin/orders.ts src/routes/admin/projects.ts src/routes/admin/quotations.ts src/utils/sequence.ts
git commit -m "refactor: extract numbering logic into numbering.service.ts"
```

---

### Task 9: Extract Offers Service

**Files:**
- Create: `src/services/offers.service.ts`
- Modify: `src/routes/admin/quotations.ts`

- [ ] **Step 1: Create offers service**

Extract the business logic from each route handler in `quotations.ts` into `src/services/offers.service.ts`. The service functions should handle Prisma queries, calculations, and data enrichment. Example:

```typescript
import prisma from '../config/database';
import { generateOfferNumber } from './numbering.service';

export async function listOffers(params: { page: number; limit: number; skip: number; sort: string; order: string; search?: string; status?: string; customerId?: number }) {
  const where: Record<string, unknown> = {};
  if (params.status) where.status = params.status;
  if (params.customerId) where.customer_id = params.customerId;
  // ... build where clause, run query, enrich results
  // Return { offers, total }
}

export async function getOffer(id: number) { /* ... */ }
export async function createOffer(data: CreateOfferInput) { /* ... */ }
export async function updateOffer(id: number, data: UpdateOfferInput) { /* ... */ }
export async function duplicateOffer(id: number) { /* ... */ }
export async function getNextOfferNumber() { return generateOfferNumber(); }
```

- [ ] **Step 2: Slim down quotations.ts routes**

Each route handler becomes:

```typescript
fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
  const query = request.query as Record<string, unknown>;
  const pagination = parsePagination(query);
  const result = await listOffers({ ...pagination, status: query.status as string, customerId: query.customer_id ? Number(query.customer_id) : undefined });
  return reply.send({ success: true, data: result.offers, pagination: buildPaginationMeta(result.total, pagination.page, pagination.limit) });
});
```

- [ ] **Step 3: Commit**

```bash
git add src/services/offers.service.ts src/routes/admin/quotations.ts
git commit -m "refactor: extract offers business logic into offers.service.ts"
```

---

### Task 10a: Extract Orders Service

**Files:**
- Create: `src/services/orders.service.ts`
- Modify: `src/routes/admin/orders.ts`

- [ ] **Step 1:** Follow the same pattern as Task 9. Move order CRUD, status transitions, project creation, and item/section management from `src/routes/admin/orders.ts` into `src/services/orders.service.ts`.

- [ ] **Step 2: Commit**

```bash
git add src/services/orders.service.ts src/routes/admin/orders.ts
git commit -m "refactor: extract orders business logic into orders.service.ts"
```

---

### Task 10b: Extract Invoices Service

**Files:**
- Create: `src/services/invoices.service.ts`
- Modify: `src/routes/admin/invoices.ts`

- [ ] **Step 1:** Move invoice CRUD, stats calculation, overdue auto-update, and PDF data preparation from `src/routes/admin/invoices.ts` into `src/services/invoices.service.ts`.

- [ ] **Step 2: Commit**

```bash
git add src/services/invoices.service.ts src/routes/admin/invoices.ts
git commit -m "refactor: extract invoices business logic into invoices.service.ts"
```

---

### Task 10c: Extract Projects Service

**Files:**
- Create: `src/services/projects.service.ts`
- Modify: `src/routes/admin/projects.ts`

- [ ] **Step 1:** Move project CRUD, notes management from `src/routes/admin/projects.ts` into `src/services/projects.service.ts`.

- [ ] **Step 2: Commit**

```bash
git add src/services/projects.service.ts src/routes/admin/projects.ts
git commit -m "refactor: extract projects business logic into projects.service.ts"
```

---

### Task 10d: Extract Users Service

**Files:**
- Create: `src/services/users.service.ts`
- Modify: `src/routes/admin/users.ts`

- [ ] **Step 1:** Move user CRUD, role assignment, password reset from `src/routes/admin/users.ts` into `src/services/users.service.ts`.

- [ ] **Step 2: Commit**

```bash
git add src/services/users.service.ts src/routes/admin/users.ts
git commit -m "refactor: extract users business logic into users.service.ts"
```

---

### Task 10e: Extract Attendance Service

**Files:**
- Create: `src/services/attendance.service.ts`
- Modify: `src/routes/admin/attendance.ts`

- [ ] **Step 1:** Move clock in/out, shift management, monthly calculations from `src/routes/admin/attendance.ts` into `src/services/attendance.service.ts`.

- [ ] **Step 2: Commit**

```bash
git add src/services/attendance.service.ts src/routes/admin/attendance.ts
git commit -m "refactor: extract attendance business logic into attendance.service.ts"
```

---

### Task 10f: Verify Service Extraction

- [ ] **Step 1: Check remaining Prisma calls in routes**

```bash
grep -rn "prisma\." src/routes/admin/ | grep -v "import" | wc -l
```

Goal: minimal direct Prisma calls in the 7 refactored route files (offers, orders, invoices, projects, users, attendance). Other route files (trips, vehicles, leave-requests, bank-accounts, etc.) are intentionally not refactored — they are simpler CRUD with minimal business logic.

- [ ] **Step 2: Commit any remaining fixes**

```bash
git add -A
git commit -m "refactor: finalize service layer extraction"
```

---

## Phase 4: Testing

### Task 11: Testing Infrastructure

**Files:**
- Create: `vitest.config.ts`
- Create: `.env.test`
- Create: `src/__tests__/setup.ts`
- Modify: `package.json`

- [ ] **Step 1: Install test dependencies**

```bash
npm install -D vitest supertest @types/supertest
```

- [ ] **Step 2: Create vitest config**

Create `vitest.config.ts`:

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

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    setupFiles: ['./src/__tests__/setup.ts'],
    testTimeout: 15000,
    hookTimeout: 15000,
  },
});
```

- [ ] **Step 3: Add .env.test to .gitignore and create .env.test.example**

Add `.env.test` to `.gitignore`:

```
.env.test
```

Create `.env.test.example` (committed, no real credentials):

```env
DATABASE_URL=mysql://user:password@127.0.0.1:3306/app_test
JWT_SECRET=test-jwt-secret-do-not-use-in-production
TOTP_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
APP_ENV=local
PORT=3099
HOST=127.0.0.1
```

Then create the actual `.env.test` locally (not committed) with real credentials.

- [ ] **Step 4: Create test setup**

Create `src/__tests__/setup.ts`:

```typescript
import dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });
```

- [ ] **Step 5: Add test scripts to package.json**

```json
"test": "vitest run",
"test:watch": "vitest"
```

- [ ] **Step 6: Commit**

```bash
git add vitest.config.ts .env.test.example .gitignore src/__tests__/setup.ts package.json package-lock.json
git commit -m "chore: add vitest testing infrastructure"
```

---

### Task 12: Auth Flow Tests

**Files:**
- Create: `src/__tests__/helpers.ts`
- Create: `src/__tests__/auth.test.ts`

- [ ] **Step 1: Create test helpers**

Create `src/__tests__/helpers.ts`:

```typescript
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit';
import authRoutes from '../routes/admin/auth';
import totpRoutes from '../routes/admin/totp';

export async function buildApp() {
  const app = Fastify({ logger: false });
  await app.register(cookie);
  await app.register(rateLimit, { max: 1000, timeWindow: '1 minute' });
  await app.register(authRoutes, { prefix: '/api/admin' });
  await app.register(totpRoutes, { prefix: '/api/admin/totp' });
  return app;
}

export function extractCookie(response: any, name: string): string | undefined {
  const cookies = response.headers['set-cookie'];
  if (!cookies) return undefined;
  const arr = Array.isArray(cookies) ? cookies : [cookies];
  for (const c of arr) {
    if (c.startsWith(`${name}=`)) {
      return c.split(';')[0].split('=')[1];
    }
  }
  return undefined;
}
```

- [ ] **Step 2: Write auth tests**

Create `src/__tests__/auth.test.ts`:

```typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp, extractCookie } from './helpers';

let app: Awaited<ReturnType<typeof buildApp>>;

beforeAll(async () => { app = await buildApp(); });
afterAll(async () => { await app.close(); });

describe('POST /api/admin/login', () => {
  it('returns 401 for invalid credentials', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/api/admin/login',
      payload: { username: 'nonexistent', password: 'wrong' },
    });
    expect(res.statusCode).toBe(401);
    expect(res.json().success).toBe(false);
  });

  it('returns 400 for missing fields', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/api/admin/login',
      payload: {},
    });
    expect(res.statusCode).toBe(400);
  });

  it('returns access token and sets refresh cookie on valid login', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/api/admin/login',
      payload: { username: 'admin', password: 'test-password' },
    });
    // Depends on test DB having an admin user
    if (res.statusCode === 200) {
      const body = res.json();
      expect(body.success).toBe(true);
      expect(body.data.access_token).toBeDefined();
      expect(extractCookie(res, 'refresh_token')).toBeDefined();
    }
  });
});

describe('POST /api/admin/refresh', () => {
  it('returns 401 without refresh token', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/api/admin/refresh',
    });
    expect(res.statusCode).toBe(401);
  });
});

describe('POST /api/admin/logout', () => {
  it('clears refresh token cookie', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/api/admin/logout',
    });
    // Should succeed even without a valid token
    expect(res.statusCode).toBeLessThan(500);
  });
});
```

- [ ] **Step 3: Run tests**

```bash
npm test
```

Expected: tests pass (or skip gracefully if test DB not seeded).

- [ ] **Step 4: Commit**

```bash
git add src/__tests__/
git commit -m "test: add auth flow integration tests"
```

---

### Task 13: Permissions & CRUD Tests

**Files:**
- Create: `src/__tests__/permissions.test.ts`
- Create: `src/__tests__/numbering.test.ts`

- [ ] **Step 1: Write permissions tests**

Create `src/__tests__/permissions.test.ts` testing:
- Unauthenticated requests return 401
- Non-admin without permission returns 403
- Admin can access all endpoints

- [ ] **Step 2: Write numbering tests**

Create `src/__tests__/numbering.test.ts` testing:
- `generateSharedNumber()` returns correct format (YYtypeCode + 4 digits)
- `generateOfferNumber()` returns correct format (YEAR/PREFIX/NNN)
- Sequential calls return incrementing numbers

- [ ] **Step 3: Run full test suite**

```bash
npm test
```

- [ ] **Step 4: Commit**

```bash
git add src/__tests__/
git commit -m "test: add permissions and numbering tests"
```

---

## Phase 5: Production Build Preparation

### Task 14: Graceful Shutdown

**Files:**
- Modify: `src/server.ts`
- Modify: `src/config/database.ts`

- [ ] **Step 1: Add shutdown handlers**

At the end of the `start()` function in `src/server.ts`, after `app.listen()`, add:

```typescript
const shutdown = async (signal: string) => {
  app.log.info(`${signal} received, shutting down gracefully...`);
  try {
    await app.close();
    const { default: prisma } = await import('./config/database');
    await prisma.$disconnect();
    app.log.info('Server shut down successfully');
    process.exit(0);
  } catch (err) {
    app.log.error(err, 'Error during shutdown');
    process.exit(1);
  }
};

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
```

- [ ] **Step 2: Commit**

```bash
git add src/server.ts
git commit -m "feat: add graceful shutdown handling (SIGTERM/SIGINT)"
```

---

### Task 15: Static File Serving (Production)

**Files:**
- Modify: `src/server.ts`

- [ ] **Step 1: Add @fastify/static for production**

In `src/server.ts`, after the dev-only Vite block, add production static serving:

```typescript
if (config.isProduction) {
  const fastifyStatic = (await import('@fastify/static')).default;
  await app.register(fastifyStatic, {
    root: path.join(__dirname, '..', 'dist-client'),
    prefix: '/',
    wildcard: false,
  });

  app.setNotFoundHandler((request, reply) => {
    if (request.url.startsWith('/api/')) {
      return reply.status(404).send({ success: false, error: 'Not found' });
    }
    return reply.sendFile('index.html');
  });
}
```

Add `import path from 'path'` at the top.

- [ ] **Step 2: Commit**

```bash
git add src/server.ts
git commit -m "feat: serve static frontend assets in production via @fastify/static"
```

---

### Task 16: Prisma Migration Baseline

- [ ] **Step 1: Create baseline migration**

```bash
mkdir -p prisma/migrations/0_init
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql
npx prisma migrate resolve --applied 0_init
```

This marks the existing schema as the baseline without running any SQL.

- [ ] **Step 2: Commit**

```bash
git add prisma/migrations/
git commit -m "chore: create Prisma migration baseline from existing schema"
```

---

### Task 17: TOTP Re-encryption Script

**Files:**
- Create: `scripts/rotate-totp-key.ts`

- [ ] **Step 1: Create rotation script**

Create `scripts/rotate-totp-key.ts`:

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

const prisma = new PrismaClient();

function decrypt(ciphertext: string, keyHex: string): string {
  const key = Buffer.from(keyHex, 'hex');
  const [ivHex, encHex, tagHex] = ciphertext.split(':');
  const iv = Buffer.from(ivHex, 'hex');
  const encrypted = Buffer.from(encHex, 'hex');
  const tag = Buffer.from(tagHex, 'hex');
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(tag);
  return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
}

function encrypt(plaintext: string, keyHex: string): string {
  const key = Buffer.from(keyHex, 'hex');
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`;
}

async function main() {
  const oldKey = process.argv[2];
  const newKey = process.argv[3];
  const dryRun = process.argv.includes('--dry-run');

  if (!oldKey || !newKey) {
    console.error('Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]');
    process.exit(1);
  }

  const users = await prisma.users.findMany({
    where: { totp_enabled: true, totp_secret: { not: null } },
    select: { id: true, username: true, totp_secret: true },
  });

  console.log(`Found ${users.length} users with TOTP enabled`);

  if (dryRun) {
    for (const user of users) {
      try {
        const decrypted = decrypt(user.totp_secret!, oldKey);
        const reEncrypted = encrypt(decrypted, newKey);
        const verify = decrypt(reEncrypted, newKey);
        console.log(`  [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`);
      } catch (e) {
        console.error(`  [FAIL] ${user.username} (id=${user.id}) — ${e}`);
      }
    }
    console.log('\nDry run complete. No changes made.');
    return;
  }

  await prisma.$transaction(async (tx) => {
    for (const user of users) {
      const decrypted = decrypt(user.totp_secret!, oldKey);
      const reEncrypted = encrypt(decrypted, newKey);
      await tx.users.update({ where: { id: user.id }, data: { totp_secret: reEncrypted } });
      console.log(`  Re-encrypted TOTP for ${user.username} (id=${user.id})`);
    }
  });

  console.log('\nAll TOTP secrets re-encrypted successfully.');
  await prisma.$disconnect();
}

main().catch((e) => { console.error(e); process.exit(1); });
```

- [ ] **Step 2: Test with dry run**

```bash
npx tsx scripts/rotate-totp-key.ts <current-key> <new-key> --dry-run
```

- [ ] **Step 3: Commit**

```bash
git add scripts/rotate-totp-key.ts
git commit -m "chore: add TOTP encryption key rotation script"
```

---

### Task 18: Final Cleanup

- [ ] **Step 1: Check for unused dependencies**

```bash
npx depcheck
```

Review output and remove any unused packages.

- [ ] **Step 2: Verify .gitignore completeness**

Ensure `dist/`, `dist-client/`, `.env`, `.env.test`, `node_modules/` are all ignored.

- [ ] **Step 3: Verify production build works**

```bash
npm run build
APP_ENV=production node dist/server.js
```

Expected: server starts without errors, serves static files, API responds.

- [ ] **Step 4: Final commit**

```bash
git add -A
git commit -m "chore: production readiness cleanup"
```
