<!-- PART OF: ARCHITECTURE.md — The 20-Minute Hero Complete Architecture -->
<!-- DOCUMENT: 06-auth-admin-seed.md -->
<!-- CONTENTS: Auth & Security, Error Handling, Weather, Admin Panel, Testing (§18), Seed Data -->
<!-- SPLIT: lines 7038–8901 of original file -->

## 15. Error Handling, Middleware & Structured Logging

### 15.1 Format błędów w API — RFC 7807 (Problem Details)

Każdy błąd zwracany przez API ma identyczną strukturę — upraszcza obsługę po stronie aplikacji mobilnej.

```json
{
  "type":       "validation_error",
  "title":      "Nieprawidłowe dane wejściowe",
  "status":     422,
  "detail":     "Pole 'file_extension' musi być jednym z: jpg, jpeg, png, mp4, mov",
  "request_id": "01J8GH3K2M...",
  "fields": {                         // Opcjonalne — tylko dla błędów walidacji
    "file_extension": "Nieobsługiwany format: gif"
  }
}
```

Pola:
- `type` — slug identyfikujący rodzaj błędu (stały, do obsługi w kodzie aplikacji)
- `title` — ludzkoczytelny opis (po polsku)
- `status` — kod HTTP (redundantny, ale wygodny przy logowaniu)
- `detail` — szczegółowy opis błędu
- `request_id` — ULID z nagłówka `X-Request-ID` — do korelacji z logami
- `fields` — mapa pól → błędy (tylko `validation_error`)

### 15.2 Hierarchia wyjątków domenowych

```typescript
// apps/api/src/errors.ts

export class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly errorType: string,
    public readonly title: string,
    public readonly detail: string = '',
    public readonly fields?: Record<string, string>,
  ) {
    super(detail)
  }
}

export class ValidationError extends AppError {
  constructor(detail: string, fields?: Record<string, string>) {
    super(422, 'validation_error', 'Nieprawidłowe dane wejściowe', detail, fields)
  }
}

export class NotFoundError extends AppError {
  constructor(detail: string) {
    super(404, 'not_found', 'Nie znaleziono zasobu', detail)
  }
}

export class UnauthorizedError extends AppError {
  constructor(detail: string) {
    super(401, 'unauthorized', 'Wymagane uwierzytelnienie', detail)
  }
}

export class ForbiddenError extends AppError {
  constructor(detail: string) {
    super(403, 'forbidden', 'Brak uprawnień', detail)
  }
}

export class ConflictError extends AppError {
  constructor(detail: string) {
    super(409, 'conflict', 'Konflikt zasobu', detail)
  }
}

export class RateLimitError extends AppError {
  constructor(detail: string) {
    super(429, 'rate_limit_exceeded', 'Zbyt wiele żądań', detail)
  }
}

export class ServiceUnavailableError extends AppError {
  constructor(detail: string) {
    super(503, 'service_unavailable', 'Usługa niedostępna', detail)
  }
}

/** Gemini API niedostępne lub błąd — użytkownik widzi 503, log zawiera szczegóły. */
export class AIServiceError extends AppError {
  constructor(detail: string) {
    super(503, 'ai_service_error', 'Usługa AI chwilowo niedostępna', detail)
  }
}
```

### 15.3 Error handlers w Hono

```typescript
// apps/api/src/index.ts (przy rejestracji aplikacji)

import { Hono } from 'hono'
import { ZodError } from 'zod'
import { AppError } from './errors'
import { log } from './logger'

const app = new Hono()

// Global error handler — obsługuje AppError i ZodError
app.onError((err, c) => {
  const requestId = c.get('requestId') ?? 'unknown'

  if (err instanceof AppError) {
    log.warn({
      event: 'app_error',
      errorType: err.errorType,
      status: err.statusCode,
      detail: err.detail,
      requestId,
      path: new URL(c.req.url).pathname,
    })
    const body: Record<string, unknown> = {
      type:      err.errorType,
      title:     err.title,
      status:    err.statusCode,
      detail:    err.detail,
      requestId,
    }
    if (err.fields) body.fields = err.fields
    return c.json(body, err.statusCode as any)
  }

  if (err instanceof ZodError) {
    // Zod walidacja body/query — formatuj jak nasz standard
    const fields: Record<string, string> = {}
    for (const issue of err.issues) {
      const field = issue.path.filter(p => p !== 'body').join('.')
      fields[field] = issue.message
    }
    return c.json({
      type:      'validation_error',
      title:     'Nieprawidłowe dane wejściowe',
      status:    422,
      detail:    'Walidacja żądania nie powiodła się',
      requestId,
      fields,
    }, 422)
  }

  // Łapie wszystkie nieoczekiwane wyjątki — loguje z traceback, zwraca 500
  log.error({ event: 'unhandled_exception', requestId, err })
  return c.json({
    type:      'internal_error',
    title:     'Błąd serwera',
    status:    500,
    detail:    'Coś poszło nie tak. Spróbuj ponownie za chwilę.',
    requestId,
  }, 500)
})
```

### 15.4 Middleware stack

```typescript
// apps/api/src/index.ts — kolejność middleware MA znaczenie

import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { cors } from 'hono/cors'
import { requestId } from 'hono/request-id'
import { createMiddleware } from 'hono/factory'
import { log } from './logger'
import { config } from './config'

const app = new Hono()

// 1. Secure headers (CSP, HSTS, etc.)
app.use(secureHeaders())

// 2. Request ID — przyjmij z nagłówka (load balancer) lub wygeneruj UUID
app.use(requestId())   // ustawia c.get('requestId') i X-Request-ID header

// 3. CORS
app.use('/api/*', cors({
  origin: config.ENVIRONMENT === 'production'
    ? ['https://app.20hero.pl']
    : ['*'],
  allowHeaders: ['Authorization', 'Content-Type', 'X-Request-ID'],
}))

// 4. Rate limiting — na poziomie endpointów (Section 26)
// Użycie na endpointach:
// quests.post('/generate', requireAuth, rateLimit('quest_generate', 3, 3600), async (c) => {...})

// 5. Request logging middleware
const requestLogger = createMiddleware(async (c, next) => {
  const requestId = c.get('requestId')
  const start = performance.now()

  await next()

  const durationMs = Math.round(performance.now() - start)
  log.info({
    event: 'http_request',
    requestId,
    method: c.req.method,
    path: new URL(c.req.url).pathname,
    status: c.res.status,
    durationMs,
  })
})

app.use(requestLogger)
```

### 15.5 Structured logging z pino

```typescript
// apps/api/src/logger.ts

import pino from 'pino'
import { config } from './config'

// json_output=true  → produkcja (logi jako JSON, łatwe do indeksowania)
// json_output=false → lokalne dev (pino-pretty — logi kolorowe, czytelne)
export const log = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  ...(config.ENVIRONMENT === 'development'
    ? { transport: { target: 'pino-pretty', options: { colorize: true } } }
    : {}),
})
```

Przykładowe logi w formacie JSON (produkcja):

```json
{"timestamp": "2026-03-09T10:15:32.412Z", "level": "info",  "event": "http_request",    "request_id": "01J8GH", "method": "POST", "path": "/api/quests/nearby", "status": 200, "duration_ms": 84.3}
{"timestamp": "2026-03-09T10:15:34.001Z", "level": "warning","event": "app_error",       "request_id": "01J8GK", "error_type": "not_found", "status": 404, "detail": "Quest nie istnieje"}
{"timestamp": "2026-03-09T10:15:35.220Z", "level": "info",  "event": "quest_generated", "request_id": "01J8GM", "user_id": "uuid...", "difficulty": "medium", "quest_id": "uuid..."}
{"timestamp": "2026-03-09T10:15:40.005Z", "level": "error", "event": "unhandled_exception", "request_id": "01J8GR", "exc_info": "Traceback...", "path": "/api/proofs/submit"}
```

### 15.6 Logowanie w serwisach — wzorzec

```typescript
// Zawsze używaj modułowego child logger (nie globalnego)
import { log as rootLog } from '../logger'
const log = rootLog.child({ module: 'proof-verify' })

// W serwisach — loguj kluczowe decyzje biznesowe
async function routeVerificationResult(proofId: string, result: ProofVerificationResult): Promise<void> {
  log.info({
    event: 'proof_routed',
    proofId,
    verdict: result.verdict,
    confidence: result.confidence,
    route: result.confidence >= 0.95 ? 'ai_verified' : 'community_voting',
  })
  // ...
}

// NIE loguj: haseł, tokenów, danych EXIF GPS, firebaseUid (PII)
// Loguj: UUID-y, statusy, metryki czasowe, decyzje systemu
```

### 15.7 BullMQ — error handling i logowanie

```typescript
// apps/api/src/jobs/proof.worker.ts

import { Worker, Job } from 'bullmq'
import { redis } from '../db/redis'
import { log as rootLog } from '../logger'

const log = rootLog.child({ module: 'proof-worker' })

const proofWorker = new Worker('proof', async (job: Job) => {
  await verifyProof(job.data.proofId)
}, { connection: redis, concurrency: 5 })

proofWorker.on('failed', (job, err) => {
  log.error({
    event: 'bullmq_job_failed',
    jobId: job?.id,
    jobName: job?.name,
    errorType: err.constructor.name,
    errorMsg: err.message,
    attemptsMade: job?.attemptsMade,
  })
})

proofWorker.on('stalled', (jobId) => {
  log.warn({ event: 'bullmq_job_stalled', jobId })
})

// Użycie z retry:
// export const proofQueue = new Queue('proof', { connection: redis })
// await proofQueue.add('verify', { proofId }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } })
```

---

## 16. Weather Service — Zewnętrzna API

### Decyzja: Open-Meteo (darmowy, bez API key)

| Provider | Koszt | API key | Limit | Jakość |
|----------|-------|---------|-------|--------|
| **Open-Meteo** | **Darmowy** | **Nie** | 10k req/dzień (bez klucza) | Dobra, ECMWF data |
| OpenWeatherMap | Free tier: 1k req/dzień | Tak | Mała dla skali | Popularna |
| WeatherAPI | Free tier: 1M req/mies | Tak | OK | Dobra |

Open-Meteo: wystarczy na 10 000 generowań questów dziennie bez żadnych kosztów. Przy skalowaniu można przejść na paid tier lub dodać API key.

### Implementacja

```typescript
// apps/api/src/services/weather.service.ts
import { z } from 'zod'
import { redis } from '../db/redis'
import { geohash4 } from '@20hero/utils/geo'

const OPEN_METEO_URL = 'https://api.open-meteo.com/v1/forecast'

// WMO Weather Code → ludzkoczytelny opis
const WMO_CODE_TO_CONDITION: Record<number, [string, string]> = {
  0:  ['clear',   'bezchmurnie'],
  1:  ['clear',   'głównie bezchmurnie'],
  2:  ['cloudy',  'częściowe zachmurzenie'],
  3:  ['cloudy',  'pochmurno'],
  45: ['fog',     'mgła'],
  48: ['fog',     'mgła z szronem'],
  51: ['drizzle', 'mżawka'],
  61: ['rain',    'lekki deszcz'],
  63: ['rain',    'umiarkowany deszcz'],
  65: ['rain',    'intensywny deszcz'],
  71: ['snow',    'lekki śnieg'],
  73: ['snow',    'umiarkowany śnieg'],
  80: ['rain',    'przelotne opady'],
  95: ['storm',   'burza'],
  99: ['storm',   'burza z gradem'],
}

export const WeatherContextSchema = z.object({
  condition:       z.string(),   // "rain", "clear", "snow", "storm", "cloudy", "fog"
  description:     z.string(),   // "umiarkowany deszcz" — do promptu AI
  tempC:           z.number(),
  windMs:          z.number(),
  precipitationMm: z.number(),
  wmoCode:         z.number().int(),
})
export type WeatherContext = z.infer<typeof WeatherContextSchema>

export function isBadWeather(w: WeatherContext): boolean {
  return ['rain', 'storm', 'snow'].includes(w.condition) || w.windMs > 10
}

export function getTimeContext(timezone: string): string {
  /** Pora dnia na podstawie lokalnego czasu gracza — przekazywana do promptu. */
  // Użyj strefy czasowej gracza (users.timezone), NIE czasu serwera
  const hour = new Date().toLocaleString('en-US', { timeZone: timezone, hour: 'numeric', hour12: false })
  const h = parseInt(hour)
  if (h >= 5  && h < 12) return 'poranek'
  if (h >= 12 && h < 18) return 'popołudnie'
  if (h >= 18 && h < 22) return 'wieczór'
  return 'noc'
}

export async function getCurrentWeather(lat: number, lon: number): Promise<WeatherContext> {
  /**
   * Pobiera aktualną pogodę dla lokalizacji.
   * Cache Redis TTL=10min — ta sama lokalizacja (geohash 4 znaki ≈ 45km²) nie odpytuje API.
   */
  const geohashKey = geohash4(lat, lon)
  const cacheKey = `weather:${geohashKey}`

  const cached = await redis.get(cacheKey)
  if (cached) return WeatherContextSchema.parse(JSON.parse(cached))

  const url = new URL(OPEN_METEO_URL)
  url.searchParams.set('latitude', String(lat))
  url.searchParams.set('longitude', String(lon))
  url.searchParams.set('current', 'temperature_2m,weathercode,windspeed_10m,precipitation')
  url.searchParams.set('wind_speed_unit', 'ms')
  url.searchParams.set('timezone', 'auto')

  const response = await fetch(url.toString(), { signal: AbortSignal.timeout(5000) })
  if (!response.ok) throw new Error(`Open-Meteo error: ${response.status}`)
  const data = (await response.json()).current

  const wmoCode = Number(data.weathercode)
  const [condition, description] = WMO_CODE_TO_CONDITION[wmoCode] ?? ['unknown', 'nieznane']

  const weather: WeatherContext = {
    condition,
    description,
    tempC:           Math.round(data.temperature_2m * 10) / 10,
    windMs:          Math.round(data.windspeed_10m * 10) / 10,
    precipitationMm: Math.round(data.precipitation * 10) / 10,
    wmoCode,
  }

  await redis.setex(cacheKey, 600, JSON.stringify(weather))   // Cache 10 minut
  return weather
}
```

Gdzie używane: `quest.service.ts → generateQuest()` → `WeatherContext` trafia do template (Sekcja 4b, zmienna `weather`).

---

## 17. Admin Panel — CRUD game_config i prompt_templates

### 17.1 Filozofia: API-first, bez UI na start

Admin panel = zestaw endpointów pod `/api/admin/`. Na MVP wystarczy Swagger UI (`/docs`) lub Postman. Dedykowany frontend (np. prosty React) można dorzucić później — API jest gotowe.

### 17.2 Schema — rozszerzenia DDL

```sql
-- Flaga admina na users
ALTER TABLE users
  ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;

-- Audit log — każda zmiana przez admina zapisana
CREATE TABLE admin_audit_log (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    admin_user_id  UUID NOT NULL REFERENCES users(id),
    action         VARCHAR(50) NOT NULL,     -- 'config_update', 'prompt_update', 'config_create'
    resource_type  VARCHAR(30) NOT NULL,     -- 'game_config', 'prompt_template'
    resource_key   VARCHAR(100) NOT NULL,
    old_value      JSONB,                    -- NULL dla nowych wpisów
    new_value      JSONB NOT NULL,
    note           TEXT,                     -- opcjonalny komentarz admina
    changed_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_log_admin   ON admin_audit_log(admin_user_id);
CREATE INDEX idx_audit_log_key     ON admin_audit_log(resource_key);
CREATE INDEX idx_audit_log_changed ON admin_audit_log(changed_at DESC);
```

### 17.3 Admin middleware

```typescript
// apps/api/src/middleware/auth.ts

import { createMiddleware } from 'hono/factory'
import { ForbiddenError } from '../errors'

/** Middleware dla endpointów admin — wymaga isAdmin=true. */
export const requireAdmin = createMiddleware(async (c, next) => {
  const user = c.get('user')
  if (!user?.isAdmin) {
    throw new ForbiddenError('Dostęp tylko dla administratorów')
  }
  await next()
})
```

### 17.4 Endpointy — game_config

```
GET    /api/admin/config
  → Lista wszystkich kluczy z wartościami i metadanymi
  Response: [{ key, value, updatedAt, description? }]

GET    /api/admin/config/{key}
  → Jeden wpis z historią ostatnich 10 zmian
  Response: { key, value, updatedAt, recentChanges: [...] }

PATCH  /api/admin/config/{key}
  Body: { value: <any JSON>, note?: string }
  → Aktualizuje wartość, invalidu je cache Redis, zapisuje audit log
  → Walidacja: value musi pasować do oczekiwanego kształtu (type guard)
  Response: { key, value, updatedAt }

POST   /api/admin/config
  Body: { key: string, value: <any JSON>, note?: string }
  → Tworzy nowy klucz (rzadko — tylko nowe features)
  → Walidacja: key nie może istnieć
  Response: 201 { key, value }
```

### 17.5 Endpointy — prompt_templates

```
GET    /api/admin/prompts
  → Lista wszystkich szablonów (bez pełnej treści — tylko key, updatedAt, preview)
  Response: [{ key, updatedAt, systemPromptPreview: string (200 chars) }]

GET    /api/admin/prompts/{key}
  → Pełna treść szablonu
  Response: { key, systemPrompt, userTemplate, updatedAt }

PUT    /api/admin/prompts/{key}
  Body: { systemPrompt?: string, userTemplate?: string, note?: string }
  → Aktualizuje jeden lub oba pola
  → Walidacja szablonu: sprawdza składnię przed zapisem
  → Invalidu je cache Redis
  → Zapisuje audit log (old/new value)
  Response: { key, updatedAt }
```

### 17.6 Implementacja — admin.service.ts

```typescript
// apps/api/src/services/admin.service.ts

import { db } from '@20hero/db'
import { gameConfig, promptTemplates, adminAuditLog } from '@20hero/db/schema'
import { eq, sql } from 'drizzle-orm'
import { redis } from '../db/redis'
import { ValidationError, NotFoundError, ConflictError } from '../errors'
import { log as rootLog } from '../logger'

const log = rootLog.child({ module: 'admin-service' })

// ── game_config ──────────────────────────────────────────────────────────────

export async function updateConfig(
  adminUserId: string,
  key: string,
  newValue: unknown,
  note?: string,
): Promise<GameConfigEntry> {
  // 1. Pobierz stary wpis (wymagany — nie tworzymy przez PATCH)
  const [old] = await db.select().from(gameConfig).where(eq(gameConfig.key, key)).limit(1)
  if (!old) throw new NotFoundError(`Klucz game_config '${key}' nie istnieje`)

  // 2. Walidacja struktury — sprawdź czy nowa wartość jest tego samego typu co stara
  validateConfigValue(key, old.value, newValue)

  // 3. Zapis do DB
  const [updated] = await db.update(gameConfig)
    .set({ value: newValue, updatedAt: new Date() })
    .where(eq(gameConfig.key, key))
    .returning()

  // 4. Natychmiastowa inwaliacja cache Redis (nie czekamy na TTL 5min)
  await redis.del(`config:${key}`)

  // 5. Audit log
  await db.insert(adminAuditLog).values({
    adminUserId,
    action:       'config_update',
    resourceType: 'game_config',
    resourceKey:  key,
    oldValue:     old.value,
    newValue,
    note,
  })

  log.info({ event: 'admin_config_updated', key, admin: adminUserId })
  return updated
}


function validateConfigValue(key: string, oldValue: unknown, newValue: unknown): void {
  /**
   * Strukturalna walidacja — nowa wartość musi mieć ten sam typ co stara.
   * Dla object: sprawdza czy wszystkie klucze z old istnieją w new (nie można usunąć klucza).
   */
  if (typeof oldValue !== typeof newValue) {
    throw new ValidationError(
      `Nieprawidłowy typ dla '${key}': oczekiwano ${typeof oldValue}, otrzymano ${typeof newValue}`
    )
  }
  if (oldValue !== null && typeof oldValue === 'object' && !Array.isArray(oldValue)) {
    const oldKeys = Object.keys(oldValue as object)
    const newKeys = new Set(Object.keys(newValue as object))
    const missing = oldKeys.filter(k => !newKeys.has(k))
    if (missing.length > 0) {
      throw new ValidationError(
        `Nowa wartość usuwa istniejące klucze: [${missing.join(', ')}]. Użyj wartości null zamiast usuwać klucze.`
      )
    }
  }
}


// ── prompt_templates ─────────────────────────────────────────────────────────

export async function updatePrompt(
  adminUserId: string,
  key: string,
  systemPrompt?: string,
  userTemplate?: string,
  note?: string,
): Promise<PromptTemplate> {
  if (!systemPrompt && !userTemplate) {
    throw new ValidationError('Podaj systemPrompt lub userTemplate (lub oba)')
  }

  const [old] = await db.select().from(promptTemplates).where(eq(promptTemplates.key, key)).limit(1)
  if (!old) throw new NotFoundError(`Szablon '${key}' nie istnieje`)

  // Walidacja składni szablonu PRZED zapisem
  if (userTemplate) validateTemplate(key, userTemplate)
  if (systemPrompt?.includes('{{')) validateTemplate(`${key}.system`, systemPrompt)

  const updates: Partial<typeof promptTemplates.$inferInsert> = { updatedAt: new Date() }
  if (systemPrompt) updates.systemPrompt = systemPrompt
  if (userTemplate) updates.userTemplate = userTemplate

  const [updated] = await db.update(promptTemplates).set(updates)
    .where(eq(promptTemplates.key, key)).returning()

  // Cache inwaliacja
  await redis.del(`prompt:${key}`)

  // Audit log — zapisujemy pełne stare i nowe wartości
  await db.insert(adminAuditLog).values({
    adminUserId,
    action:       'prompt_update',
    resourceType: 'prompt_template',
    resourceKey:  key,
    oldValue:     { systemPrompt: old.systemPrompt, userTemplate: old.userTemplate },
    newValue:     { systemPrompt: updated.systemPrompt, userTemplate: updated.userTemplate },
    note,
  })

  log.info({ event: 'admin_prompt_updated', key, admin: adminUserId })
  return updated
}


function validateTemplate(key: string, templateStr: string): void {
  // Weryfikacja szablonu — sprawdza czy zmienne {{ var }} są poprawnie zamknięte
  const openCount = (templateStr.match(/\{\{/g) ?? []).length
  const closeCount = (templateStr.match(/\}\}/g) ?? []).length
  if (openCount !== closeCount) {
    throw new ValidationError(`Błąd składni szablonu '${key}': niezamknięte {{ lub }}`)
  }
}
```

### 17.7 Router — admin.ts

```typescript
// apps/api/src/routes/admin.ts

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { requireAuth, requireAdmin } from '../middleware/auth'
import * as adminService from '../services/admin.service'
import {
  ConfigUpdateRequestSchema, ConfigCreateRequestSchema,
  PromptUpdateRequestSchema,
} from '@20hero/types'
import { db } from '@20hero/db'
import { gameConfig, promptTemplates, adminAuditLog } from '@20hero/db/schema'
import { eq, desc, and } from 'drizzle-orm'
import { NotFoundError, ConflictError } from '../errors'

const admin = new Hono()
admin.use(requireAuth, requireAdmin)

// ── game_config ──────────────────────────────────────────────────────────────

admin.get('/config', async (c) => {
  const entries = await db.select().from(gameConfig).orderBy(gameConfig.key)
  return c.json(entries)
})

admin.get('/config/:key', async (c) => {
  const key = c.req.param('key')
  const [entry] = await db.select().from(gameConfig).where(eq(gameConfig.key, key)).limit(1)
  if (!entry) throw new NotFoundError(`Klucz '${key}' nie istnieje`)
  const recent = await db.select().from(adminAuditLog)
    .where(and(eq(adminAuditLog.resourceType, 'game_config'), eq(adminAuditLog.resourceKey, key)))
    .orderBy(desc(adminAuditLog.changedAt)).limit(10)
  return c.json({ entry, recentChanges: recent })
})

admin.patch('/config/:key', zValidator('json', ConfigUpdateRequestSchema), async (c) => {
  const key = c.req.param('key')
  const { value, note } = c.req.valid('json')
  const adminUser = c.get('user')
  const updated = await adminService.updateConfig(adminUser.id, key, value, note)
  return c.json(updated)
})

admin.post('/config', zValidator('json', ConfigCreateRequestSchema), async (c) => {
  const { key, value, note } = c.req.valid('json')
  const existing = await db.select().from(gameConfig).where(eq(gameConfig.key, key)).limit(1)
  if (existing.length > 0) throw new ConflictError(`Klucz '${key}' już istnieje — użyj PATCH`)
  const [created] = await db.insert(gameConfig).values({ key, value }).returning()
  return c.json(created, 201)
})

// ── prompt_templates ─────────────────────────────────────────────────────────

admin.get('/prompts', async (c) => {
  const templates = await db.select().from(promptTemplates).orderBy(promptTemplates.key)
  return c.json(templates.map(t => ({
    key: t.key,
    updatedAt: t.updatedAt,
    systemPromptPreview: t.systemPrompt.slice(0, 200),
  })))
})

admin.get('/prompts/:key', async (c) => {
  const key = c.req.param('key')
  const [tmpl] = await db.select().from(promptTemplates).where(eq(promptTemplates.key, key)).limit(1)
  if (!tmpl) throw new NotFoundError(`Szablon '${key}' nie istnieje`)
  return c.json(tmpl)
})

admin.put('/prompts/:key', zValidator('json', PromptUpdateRequestSchema), async (c) => {
  const key = c.req.param('key')
  const { systemPrompt, userTemplate, note } = c.req.valid('json')
  const adminUser = c.get('user')
  const updated = await adminService.updatePrompt(adminUser.id, key, systemPrompt, userTemplate, note)
  return c.json(updated)
})

// ── audit log ────────────────────────────────────────────────────────────────

admin.get('/audit-log', async (c) => {
  /** Historia wszystkich zmian — filtrowalna po typie i kluczu. */
  const resourceType = c.req.query('resource_type')
  const resourceKey  = c.req.query('resource_key')
  const limit = Math.min(parseInt(c.req.query('limit') ?? '50'), 200)

  const conditions = []
  if (resourceType) conditions.push(eq(adminAuditLog.resourceType, resourceType))
  if (resourceKey)  conditions.push(eq(adminAuditLog.resourceKey, resourceKey))

  const entries = await db.select().from(adminAuditLog)
    .where(conditions.length ? and(...conditions) : undefined)
    .orderBy(desc(adminAuditLog.changedAt)).limit(limit)
  return c.json(entries)
})

export { admin as adminRouter }
```

### 17.8 Schematy request/response

```typescript
// packages/types/src/admin.ts
import { z } from 'zod'

export const ConfigUpdateRequestSchema = z.object({
  value: z.unknown(),      // Dowolna wartość JSON — walidacja w serwisie
  note:  z.string().nullable().optional(),
})

export const ConfigCreateRequestSchema = z.object({
  key:   z.string(),
  value: z.unknown(),
  note:  z.string().nullable().optional(),
})

export const GameConfigResponseSchema = z.object({
  key:       z.string(),
  value:     z.unknown(),
  updatedAt: z.string().datetime(),
})

export const GameConfigDetailResponseSchema = z.object({
  entry:         GameConfigResponseSchema,
  recentChanges: z.array(AuditLogEntrySchema),
})

export const PromptUpdateRequestSchema = z.object({
  systemPrompt: z.string().nullable().optional(),
  userTemplate: z.string().nullable().optional(),
  note:         z.string().nullable().optional(),
})

export const PromptListResponseSchema = z.object({
  key:                  z.string(),
  updatedAt:            z.string().datetime(),
  systemPromptPreview:  z.string(),   // Pierwsze 200 znaków
})

export const PromptDetailResponseSchema = z.object({
  key:          z.string(),
  systemPrompt: z.string(),
  userTemplate: z.string(),
  updatedAt:    z.string().datetime(),
})

export const AuditLogEntrySchema = z.object({
  id:           z.string().uuid(),
  adminUserId:  z.string().uuid(),
  action:       z.string(),
  resourceType: z.string(),
  resourceKey:  z.string(),
  oldValue:     z.unknown(),
  newValue:     z.unknown(),
  note:         z.string().nullable(),
  changedAt:    z.string().datetime(),
})

export type ConfigUpdateRequest = z.infer<typeof ConfigUpdateRequestSchema>
export type PromptUpdateRequest = z.infer<typeof PromptUpdateRequestSchema>
export type AuditLogEntry = z.infer<typeof AuditLogEntrySchema>
```

### 17.9 Bezpieczeństwo i edge cases

| Problem | Rozwiązanie |
|---------|-------------|
| Przypadkowe usunięcie klucza z dict | `_validate_config_value` blokuje usunięcie kluczy — musisz ustawić na `null`, nie usuwać |
| Jinja2 injection w prompt | Sandbox `Environment` — `parse()` bez `render()`, wykrywa składnię bez wykonania |
| Admin lockout (jedyny admin usuwa flagę sobie) | `update_config` dla `is_admin` disabled — flaga zmieniana tylko przez SQL na produkcji |
| Prompt z błędnymi zmiennymi (typo) | Walidacja składni wykrywa `{{ uzytkownik.levle }}` (zła składnia) ale nie semantykę — warto testować prompt przed deployem |
| Równoczesne zmiany przez 2 adminów | `updated_at` w odpowiedzi — frontend może sprawdzić freshness przed PATCH |
| Rollback zmiany | Użyj `GET /api/admin/audit-log?resource_key={key}` → weź `old_value` → `PATCH` z tą wartością |

---

## 18. Testing Strategy

### 18.1 Piramida testów

```
          ┌───────────────────────────────┐
          │   E2E (nieliczne, powolne)     │  ← pełny flow: auth → quest → proof → XP
          ├───────────────────────────────┤
          │  Integration (API + real DB)   │  ← endpoints, mocked zewnętrznych API
          ├───────────────────────────────┤
          │   Unit (dużo, szybkie)         │  ← czysta logika biznesowa, zero I/O
          └───────────────────────────────┘
```

Proporcje: **~60% unit / ~35% integration / ~5% E2E**

### 18.2 Stack techniczny

```
vitest                        — runner, fixtures (beforeAll/beforeEach), parametrize (it.each)
hono/testing testClient       — HTTP klient dla integration testów (bez uruchamiania serwera)
@testcontainers/postgresql    — prawdziwe PostgreSQL per test session (z PostGIS!)
ioredis-mock                  — Redis w pamięci, bez procesu
msw                           — mock HTTP calls (Open-Meteo, Nominatim, S3 presigned URL)
@faker-js/faker               — fabryki danych testowych (zamiast ręcznych INSERT-ów)
vitest-mock-extended          — vi.mock() helpers
@sinonjs/fake-timers          — zamrożenie czasu (quiet hours, TTL, streak logic)
```

Instalacja:
```
pnpm add -D vitest @faker-js/faker msw ioredis-mock @testcontainers/postgresql vitest-mock-extended @sinonjs/fake-timers
```

### 18.3 Konfiguracja Vitest

```typescript
// apps/api/vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,                 // describe/it/expect bez importu
    environment: 'node',
    include: ['tests/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      include: ['src/**/*.ts'],
      thresholds: {
        'src/services/**': { lines: 80 },
        'src/api/**':      { lines: 70 },
        'src/jobs/**':     { lines: 70 },
      },
    },
  },
})
```

### 18.4 Struktura katalogów testów

```
tests/
├── setup.ts                    # Globalne beforeAll: testcontainer PostgreSQL + drizzle migrate
├── factories.ts                # @faker-js/faker: makeUser(), makeQuest(), makeProof()...
├── unit/
│   ├── xp-service.test.ts
│   ├── voting-service.test.ts
│   ├── proof-routing.test.ts
│   ├── admin-validation.test.ts
│   ├── push-rate-limit.test.ts
│   └── config-service.test.ts
├── integration/
│   ├── auth.test.ts
│   ├── onboarding.test.ts
│   ├── quests.test.ts
│   ├── proofs.test.ts
│   ├── voting.test.ts
│   ├── social.test.ts
│   ├── admin.test.ts
│   └── geospatial.test.ts
└── jobs/
    ├── verify-proof.test.ts
    ├── avatar-jobs.test.ts
    └── maintenance-jobs.test.ts
```

### 18.5 Główne fixtures — setup.ts + helpers

```typescript
// tests/setup.ts
import { beforeAll, afterAll, beforeEach } from 'vitest'
import { PostgreSqlContainer } from '@testcontainers/postgresql'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import { IORedisMock } from 'ioredis-mock'

let container: PostgreSqlContainer
export let db: ReturnType<typeof drizzle>
export let redis: IORedisMock

beforeAll(async () => {
  // Prawdziwe PostgreSQL z PostGIS — per test suite
  container = await new PostgreSqlContainer('postgis/postgis:16-3.4').start()
  const sql = postgres(container.getConnectionUri())
  db = drizzle(sql)
  await migrate(db, { migrationsFolder: 'packages/db/drizzle' })

  // Redis w pamięci
  redis = new IORedisMock()
})

afterAll(async () => {
  await container.stop()
  redis.disconnect()
})

beforeEach(async () => {
  // Wyczyść tabele przed każdym testem zamiast rollbacka transakcji
  await db.execute(sql`TRUNCATE users, characters, quests, quest_attempts,
    quest_proofs, game_config, prompt_templates RESTART IDENTITY CASCADE`)
  await redis.flushall()
})

// tests/helpers.ts — auth + testClient
import { testClient } from 'hono/testing'
import { app } from '../src/app'
import { signAccessToken } from '../src/auth/tokens'

export function makeAuthHeaders(user: { id: string; isAdmin?: boolean }) {
  const token = signAccessToken({ sub: user.id, isAdmin: user.isAdmin ?? false })
  return { Authorization: `Bearer ${token}` }
}

export const client = testClient(app)

// tests/factories.ts — @faker-js/faker
import { faker } from '@faker-js/faker'

export function makeUser(overrides = {}) {
  return {
    id:          faker.string.uuid(),
    firebaseUid: faker.string.alphanumeric(28),
    email:       faker.internet.email(),
    displayName: faker.person.fullName(),
    isAdmin:     false,
    ...overrides,
  }
}

export function makeQuest(overrides = {}) {
  return {
    id:          faker.string.uuid(),
    title:       faker.lorem.sentence(4),
    difficulty:  'easy' as const,
    lat:         faker.location.latitude(),
    lon:         faker.location.longitude(),
    radiusVisibleM: 500,
    ...overrides,
  }
}
```

### 18.6 Mocking zewnętrznych serwisów

```typescript
// tests/mocks.ts — vi.mock() + MSW handlers

import { vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

// ── MSW server — startowany raz dla całej suity ───────────────────────────────
export const mswServer = setupServer()
beforeAll(() => mswServer.listen({ onUnhandledRequest: 'error' }))
afterEach(() => mswServer.resetHandlers())
afterAll(() => mswServer.close())

// ── Gemini AI ─────────────────────────────────────────────────────────────────

export const MOCK_QUEST_OUTPUT = {
  title:            "Protokół Neonowych Refleksów",
  description:      "Twój sensor wykrył anomalię w sieci miejskiej...",
  objective:        "Sfotografuj 3 różne lampy uliczne LED w promieniu 300m",
  completionHint:   "Zdjęcie lamp ulicznych z widocznym światłem",
  loreBlurb:        "Każda lampa to węzeł sieci Nexus...",
  difficulty:       "easy",
  estimatedMinutes: 20,
  questTags:        ["outdoor", "observation", "urban"],
  aiReasoning:      "Level 1, deszcz → łatwy quest w pobliżu",
}

export function mockGeminiQuest() {
  return vi.spyOn(questAiService, 'callGemini').mockResolvedValue(MOCK_QUEST_OUTPUT)
}

export function mockGeminiVerifyPass() {
  return vi.spyOn(proofVerificationService, 'callGemini').mockResolvedValue({
    verdict: 'pass', confidence: 0.97,
    showsRequiredElement: true, looksAuthentic: true,
    locationPlausible: true, timingPlausible: true,
    detectedElements: ["lampy uliczne LED", "mokra ulica"],
    flags: [], reasoning: "Wyraźne lampy, GPS ok",
    userFeedback: "Świetne zaliczenie!",
  })
}

export function mockGeminiVerifyFail() {
  return vi.spyOn(proofVerificationService, 'callGemini').mockResolvedValue({
    verdict: 'fail', confidence: 0.91,
    showsRequiredElement: false, looksAuthentic: true,
    locationPlausible: true, timingPlausible: true,
    detectedElements: ["park", "drzewo"],
    flags: ["missing_required_element"],
    reasoning: "Brak lamp ulicznych na zdjęciu",
    userFeedback: "Na zdjęciu nie widać wymaganych lamp ulicznych.",
  })
}

// ── Firebase Auth ─────────────────────────────────────────────────────────────

export function mockFirebase() {
  return vi.spyOn(firebaseAdmin.auth(), 'verifyIdToken').mockResolvedValue({
    uid:   'firebase-test-uid-123',
    email: 'test@example.com',
    name:  'Test User',
  } as any)
}

// ── S3 — MSW handler ──────────────────────────────────────────────────────────

export const s3Handlers = [
  http.put('https://s3.fake/presigned-url', () => HttpResponse.text('', { status: 200 })),
]

export function mockS3() {
  vi.spyOn(s3Service, 'generatePresignedUrl').mockResolvedValue('https://s3.fake/presigned-url')
  vi.spyOn(s3Service, 'objectExists').mockResolvedValue(true)
  vi.spyOn(s3Service, 'download').mockResolvedValue(Buffer.from('fake-image-bytes'))
}

// ── Open-Meteo — MSW handler ──────────────────────────────────────────────────

export const weatherHandler = http.get(
  'https://api.open-meteo.com/v1/forecast',
  () => HttpResponse.json({
    current: {
      temperature_2m: 12.5,
      weathercode:    61,    // lekki deszcz
      windspeed_10m:  5.2,
      precipitation:  0.8,
    },
  }),
)

// ── FCM Push ──────────────────────────────────────────────────────────────────

export function mockFcm() {
  return vi.spyOn(firebaseAdmin.messaging(), 'sendEachForMulticast').mockResolvedValue({
    successCount: 1, failureCount: 0,
    responses: [{ success: true, messageId: 'fake-msg-id' }],
  })
}
```

### 18.7 Testy jednostkowe — co i jak

```typescript
// tests/unit/xp-service.test.ts

import { describe, it, expect } from 'vitest'
import { totalXpForLevel, checkLevelUp } from '../../src/services/xp.service'

describe('XP formula', () => {
  it.each([
    [1,  0],        // 80*1*0 = 0
    [2,  160],      // 80*2*1 = 160
    [5,  1600],     // 80*5*4 = 1600
    [10, 7200],
  ])('level %i → %i XP', (level, expectedXp) => {
    expect(totalXpForLevel(level)).toBe(expectedXp)
  })

  it('triggers level up at threshold', () => {
    expect(checkLevelUp({ level: 2, totalXp: 159 })).toBe(false)
    expect(checkLevelUp({ level: 2, totalXp: 160 })).toBe(true)
  })
})

// tests/unit/voting-service.test.ts

import { vi } from 'vitest'
import { calculateOutcome } from '../../src/services/voting.service'
import * as configService from '../../src/services/config.service'

describe('Vote resolution', () => {
  beforeEach(() => {
    vi.spyOn(configService, 'getSync').mockReturnValue({
      approveThreshold: 0.60,
      rejectThreshold:  0.35,
      tieOutcome:       'approve',
    })
  })

  it.each([
    [3, 5, 'accepted'],    // 0.60 = próg approve
    [4, 5, 'accepted'],    // 0.80 > próg
    [1, 5, 'rejected'],    // 0.20 < próg reject
    [2, 5, 'approve'],     // 0.40 = tie → tieOutcome="approve"
  ])('%i/%i votes → %s', (approve, total, expected) => {
    expect(calculateOutcome(approve, total)).toBe(expected)
  })
})

// tests/unit/proof-routing.test.ts

it.each([
  ['pass',      0.97, 'ai_verified'],
  ['pass',      0.80, 'community_voting'],
  ['uncertain', 0.60, 'community_voting'],
  ['fail',      0.90, 'ai_rejected'],
  ['fail',      0.70, 'community_voting'],
])('verdict=%s confidence=%f → %s', (verdict, confidence, expectedRoute) => {
  const result = { verdict, confidence } as ProofVerificationResult
  expect(determineRoute(result)).toBe(expectedRoute)
})

// tests/unit/push-rate-limit.test.ts

it('blocks after max per hour', async () => {
  const userId = faker.string.uuid()
  const cfg = { rateLimits: { maxPerHour: 5, maxPerDay: 20 } }
  // Pierwszych 5 — przechodzi
  for (let i = 0; i < 5; i++) {
    expect(await pushService.checkRateLimit(userId, redis, cfg)).toBe(true)
  }
  // 6. — zablokowane
  expect(await pushService.checkRateLimit(userId, redis, cfg)).toBe(false)
})

// tests/unit/admin-validation.test.ts

it('rejects wrong type', () => {
  expect(() => validateConfigValue('xp', { base: 100 }, 100))
    .toThrow(/typ/)
})

it('rejects missing keys', () => {
  expect(() => validateConfigValue('xp', { base: 100, multiplier: 1.5 }, { base: 120 }))
    .toThrow(/usuwa/)
})

it('catches template syntax error', () => {
  expect(() => validateTemplate('test_key', 'Witaj {{ user.name'))
    .toThrow(/template/)
})
```

### 18.8 Testy integracyjne — kluczowe scenariusze

```typescript
// tests/integration/auth.test.ts

import { describe, it, expect, beforeEach } from 'vitest'
import { client, makeAuthHeaders } from '../helpers'
import { mockFirebase, mockGeminiVerifyPass, mockFcm, mockS3 } from '../mocks'
import { db } from '../setup'
import { users } from '../../packages/db/src/schema'
import { eq } from 'drizzle-orm'

describe('Firebase login', () => {
  beforeEach(() => { mockFirebase() })

  it('creates user on first login', async () => {
    const res = await client.api.auth.firebase.$post({ json: { idToken: 'fake-token' } })

    expect(res.status).toBe(200)
    const data = await res.json()
    expect(data).toHaveProperty('accessToken')
    expect(data).toHaveProperty('refreshToken')
    expect(data.user.characterClass).toBeNull()   // Przed onboardingiem

    // User faktycznie w bazie
    const [user] = await db.select().from(users).where(eq(users.firebaseUid, 'firebase-test-uid-123'))
    expect(user).toBeDefined()
  })

  it('rotates refresh token', async () => {
    const login = await client.api.auth.firebase.$post({ json: { idToken: 'fake' } })
    const { refreshToken: oldRefresh } = await login.json()

    const refresh = await client.api.auth.refresh.$post({ json: { refreshToken: oldRefresh } })
    expect(refresh.status).toBe(200)
    const { refreshToken: newRefresh } = await refresh.json()
    expect(newRefresh).not.toBe(oldRefresh)

    // Stary token nie działa
    const retry = await client.api.auth.refresh.$post({ json: { refreshToken: oldRefresh } })
    expect(retry.status).toBe(401)
  })

  it('expired token returns 401', async () => {
    vi.useFakeTimers()
    const user = await insertUser()
    const headers = makeAuthHeaders(user)

    vi.advanceTimersByTime(20 * 60 * 1000)   // 20 minut do przodu

    const res = await client.api.quests.nearby.$get(
      { query: { lat: '50.06', lon: '19.94' } },
      { headers },
    )
    expect(res.status).toBe(401)
    expect((await res.json()).type).toBe('unauthorized')
    vi.useRealTimers()
  })
})

// tests/integration/proofs.test.ts

it('full proof flow — auto verified', async () => {
  const mockVerify = mockGeminiVerifyPass()
  const mockPush   = mockFcm()
  mockS3()

  const user  = await insertUser({ characterClass: 'techno_mag' })
  const quest = await insertQuest({ status: 'in_progress', userId: user.id })
  const headers = makeAuthHeaders(user)

  // 1. Pobierz presigned URL
  const urlRes = await client.api.proofs[':questId']['upload-url'].$post({
    param: { questId: quest.id },
    json: { fileExtension: 'jpg', fileSizeBytes: 2_000_000 },
    headers,
  })
  expect(urlRes.status).toBe(200)
  const { s3Key } = await urlRes.json()

  // 2. Submit
  const submitRes = await client.api.proofs[':questId'].submit.$post({
    param: { questId: quest.id },
    json: { s3Key },
    headers,
  })
  expect(submitRes.status).toBe(200)
  const { proofId } = await submitRes.json()

  // 3. Uruchom BullMQ processor bezpośrednio (bez brokera)
  await verifyProofProcessor({ data: { proofId } } as Job)

  // 4. Status — AI auto-verified
  const statusRes = await client.api.proofs[':proofId'].status.$get({
    param: { proofId }, headers,
  })
  const status = await statusRes.json()
  expect(status.status).toBe('ai_verified')
  expect(status.xpAwarded).toBeGreaterThan(0)

  // 5. Push notification wysłany
  expect(mockPush).toHaveBeenCalledOnce()
})

// tests/integration/geospatial.test.ts

it('nearby quests respects radius', async () => {
  const user = await insertUser()
  await insertQuest({ lat: 50.063, lon: 19.940, radiusVisibleM: 500 })   // 300m — widoczny
  await insertQuest({ lat: 50.067, lon: 19.940, radiusVisibleM: 500 })   // 600m — poza

  const res = await client.api.quests.nearby.$get({
    query: { lat: '50.060', lon: '19.940' },
    headers: makeAuthHeaders(user),
  })
  expect(res.status).toBe(200)
  expect(await res.json()).toHaveLength(1)
})

// tests/integration/admin.test.ts

it('config update invalidates redis cache', async () => {
  const admin = await insertUser({ isAdmin: true })
  await db.insert(gameConfig).values({ key: 'xp', value: { base: 100 } })
  await redis.set('config:xp', JSON.stringify({ base: 100 }))

  const res = await client.api.admin.config[':key'].$patch({
    param: { key: 'xp' },
    json: { value: { base: 150 }, note: 'test' },
    headers: makeAuthHeaders(admin),
  })
  expect(res.status).toBe(200)
  expect(await redis.get('config:xp')).toBeNull()
})

it('template update validates syntax', async () => {
  const admin = await insertUser({ isAdmin: true })
  const res = await client.api.admin.prompts[':key'].$put({
    param: { key: 'quest_generation' },
    json: { userTemplate: 'Witaj {{ user.name' },   // błąd składni
    headers: makeAuthHeaders(admin),
  })
  expect(res.status).toBe(422)
  expect((await res.json()).detail).toMatch(/template/)
})

it('non-admin blocked', async () => {
  const user = await insertUser()
  const res = await client.api.admin.config.$get({ headers: makeAuthHeaders(user) })
  expect(res.status).toBe(403)
  expect((await res.json()).type).toBe('forbidden')
})
```

### 18.9 Testy BullMQ jobs

```typescript
// tests/jobs/verify-proof.test.ts
// BullMQ processor wywołany bezpośrednio — bez brokera, bez Workera

import { verifyProofProcessor } from '../../src/jobs/verify-proof.job'
import type { Job } from 'bullmq'

it('routes to community_voting when confidence below threshold', async () => {
  const spy = mockGeminiVerifyPass()
  spy.mockResolvedValueOnce({ ...MOCK_VERIFY_PASS, confidence: 0.82 })
  mockS3()

  const proof = await insertProof({ status: 'submitted' })
  await verifyProofProcessor({ data: { proofId: proof.id } } as Job)

  const updated = await db.query.questProofs.findFirst({
    where: eq(questProofs.id, proof.id),
  })
  expect(updated!.status).toBe('community_voting')
})

it('throws on Gemini error (BullMQ will retry)', async () => {
  vi.spyOn(proofVerificationService, 'callGemini').mockRejectedValue(
    new AIServiceError('Gemini timeout'),
  )
  mockS3()
  const proof = await insertProof({ status: 'submitted' })

  await expect(
    verifyProofProcessor({ data: { proofId: proof.id } } as Job),
  ).rejects.toThrow(AIServiceError)
  // BullMQ automatycznie retryuje job po rzuceniu wyjątku
})

// tests/jobs/maintenance-jobs.test.ts

it('closes expired quest attempts', async () => {
  vi.useFakeTimers()
  const expiredAt = new Date(Date.now() - 10 * 60 * 1000)  // 10 min temu
  const attempt = await insertQuestAttempt({ status: 'in_progress', expiresAt: expiredAt })

  await closeExpiredQuestsProcessor({} as Job)

  const updated = await db.query.questAttempts.findFirst({
    where: eq(questAttempts.id, attempt.id),
  })
  expect(updated!.status).toBe('expired')
  vi.useRealTimers()
})
```

### 18.10 Czego NIE testujemy

| Co | Dlaczego nie |
|----|-------------|
| Faktyczne wywołania Gemini API | Drogie ($), wolne, niedeterministyczne — zawsze mock |
| Faktyczna weryfikacja Firebase token | Wymaga sieci + prawdziwego tokenu — zawsze mock |
| Faktyczne wysyłanie FCM push | Wymaga prawdziwych tokenów urządzeń — zawsze mock |
| Faktyczny upload do S3 | Moto lub mock — unikamy kosztów i sieci w CI |
| Faktyczne zapytania do Nominatim | Rate limit 1 req/s — respx mock |
| BullMQ z prawdziwym brokerem | W testach: processor wywoływany bezpośrednio; do testów kolejki osobny smoke test |
| Zod `responseSchema` poprawność | Gemini gwarantuje format — testujemy routing logikę, nie format |

### 18.11 CI/CD — uruchamianie testów

```yaml
# .github/workflows/test.yml
# Testcontainers startuje PostgreSQL wewnętrznie — nie potrzeba services:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter @20hero/api vitest run tests/unit/       # Najpierw unit — szybkie
      - run: pnpm --filter @20hero/api vitest run tests/integration/ # Potem integration z DB
      - run: pnpm --filter @20hero/api vitest run tests/jobs/        # BullMQ processors
      - run: pnpm --filter @20hero/api vitest run --coverage
```

**Progi pokrycia** (coverage):
- `services/` — minimum **80%** (logika biznesowa)
- `repositories/` — **50%** (SQL queries — testowane pośrednio przez integration)
- `api/routers/` — **70%** (happy path + główne błędy)
- `tasks/` — **70%**

---

## 19. Seed Data — `packages/db/src/seed.ts`

Skrypt wypełnia `game_config` i `prompt_templates` wartościami startowymi.
Idempotentny (`ON CONFLICT DO NOTHING`) — można uruchamiać wielokrotnie.

### 19.1 Uruchamianie

```bash
# Jednorazowo po `pnpm drizzle-kit migrate`
pnpm --filter @20hero/db seed

# Lub bezpośrednio
npx tsx packages/db/src/seed.ts
```

### 19.2 Struktura skryptu

```typescript
// packages/db/src/seed.ts
/**
 * Seed startowych danych do game_config i prompt_templates.
 * Uruchom po: pnpm drizzle-kit migrate
 * Idempotentny — bezpieczny przy wielokrotnym wywołaniu.
 */
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
import { gameConfig, promptTemplates } from './schema'
import { config } from './config'

const sql = postgres(config.DATABASE_URL)
const db  = drizzle(sql)

async function seed() {
  await seedGameConfig()
  await seedPromptTemplates()
  await seedShowcaseProofs()   // cold start — zapełnia feed przed pierwszymi userami
  console.log('✅ Seed zakończony.')
  await sql.end()
}

seed().catch((err) => { console.error(err); process.exit(1) })
```

### 19.3 game_config — wszystkie klucze z konkretnymi wartościami

```typescript
async function seedGameConfig() {
  const entries: Record<string, unknown> = {

        # ── XP i levelowanie ──────────────────────────────────────────────────
        # Flat key: używany przez xp_service.award_quest_xp() → config.get("xp_per_difficulty")
        "xp_per_difficulty": {
            "easy":      100,
            "medium":    200,
            "hard":      350,
            "legendary": 600,
        },

        # Leveling: używane przez _level_for_xp(xp, coeff, max_level)
        # Formuła: total_xp_for_level(N) = xp_coefficient * N * (N-1)
        # L1=0, L2=160, L5=1600, L10=7200, L20=30400, L50=196000, L120=1142400
        "leveling": {
            "xp_coefficient": 80,   # total_xp_for_level(N) = xp_coefficient * N * (N-1)
            "max_level": 120,
        },

        "xp": {
            # XP bonusowe (max, przy 100% approve w głosowaniu lub AI_VERIFIED)
            # UWAGA: bazowe XP per trudność jest w kluczu "xp_per_difficulty" (nie tutaj)
            "bonus_xp_max": {
                "easy":      50,
                "medium":    100,
                "hard":      200,
                "legendary": 400,
            },
            # Streak multiplier: dni_streaka → mnożnik na bonus_xp
            "streak_multipliers": {
                "1":  1.0,
                "3":  1.1,
                "7":  1.25,
                "14": 1.5,
                "30": 2.0,
            },
            # XP za głosowanie (Sekcja 8b)
            "voter_immediate_xp":      5,
            "voter_accuracy_bonus_xp": 5,
            "voter_daily_xp_cap":      100,
        },

        # ── Tokeny HERO ───────────────────────────────────────────────────────
        # Kalibracja: casual player (~1 medium quest/day + voting) ≈ 40-50 HERO/tydzień
        #             active player (cap dzienny)                  ≈ 100-140 HERO/tydzień
        "token_rewards": {
            "enabled": true,      // Naliczanie wirtualnego salda od dnia 1
            "per_difficulty": {
                "easy":      2,    # Szybkie questy w okolicy
                "medium":    5,    # Główna dieta gracza
                "hard":      12,   # Wymagające, rzadsze
                "legendary": 25,   # Prestiżowe, endgame
            },
            "voter_reward": 1,    # HERO za oddany głos (max voter_daily_cap nagradzanych/dzień)
            "voter_daily_cap": 3, # Max 3 głosy nagradzane dziennie (= 3 HERO/dzień z voting)
            "first_quest_daily": 2,  # Bonus za pierwszy ukończony quest dnia
            "daily_cap": 20,      # Max HERO dziennie ze wszystkich źródeł łącznie
            "class_match_bonus": 0.25,  # +25% base_tokens gdy quest.tags ∩ class_tags
            "streak_bonus": {     # Jednorazowe HERO przy milestone passy (nie mnożnik)
                "7":  5,          # Tydzień z rzędu
                "30": 20,         # Miesiąc z rzędu
                "100": 50,        # 100 dni — prestiż
            },
        },

        # ── Questy — timery i parametry ───────────────────────────────────────
        "quests": {
            "timer_duration_minutes":    25,   # Czas od akceptacji do wygaśnięcia
            "grace_period_minutes":       5,   # Dodatkowy czas po nominalnym końcu
            "warning_before_expiry_min":  5,   # Kiedy push "wygasa za 5 min"
            "abandon_cooldown_minutes":  30,   # Cooldown po porzuceniu questa
            "max_active_quests":          1,   # Tylko 1 quest naraz
            "nearby_radius_default_m":  500,   # Domyślny promień wyszukiwania
            "deduplicate_window_days":    7,   # Nie powtarzaj questa przez X dni
            "difficulty_distribution": {       # Procentowy rozkład trudności
                "easy":      0.45,
                "medium":    0.35,
                "hard":      0.15,
                "legendary": 0.05,
            },
            "difficulty_unlock_level": {       # Od którego levelu odblokowane
                "easy":      1,
                "medium":    3,
                "hard":      10,
                "legendary": 25,
            },
        },

        # ── Community Voting ──────────────────────────────────────────────────
        "voting": {
            "voter_min_level":         3,
            "min_votes":               3,      # Minimalna liczba głosów do rozstrzygnięcia
            "min_votes_fast_resolve":  5,      # Min głosów do szybkiego rozstrzygnięcia
            "fast_resolve_ratio":      0.85,   # Przy ≥85% jednej opcji → natychmiastowe
            "voting_duration_hours":  24,      # Czas na zebranie głosów
            "approve_threshold":       0.60,   # ≥60% → zatwierdzone
            "reject_threshold":        0.35,   # ≤35% → odrzucone
            "tie_outcome":             "approve",  # Remis → zatwierdzone (benefit of doubt)
            "zero_vote_outcome":       "rejected", # 0 głosów → odrzucone (anti-farming: brak nagrody)
            "voter_immediate_xp":      5,      # XP od razu po głosowaniu
            "voter_accuracy_bonus_xp": 5,      # XP po rozstrzygnięciu (trafność)
            "voter_daily_xp_cap":    100,      # Max XP z głosowania dziennie
            "vote_same_user_cooldown_h": 1,    # Przerwa między głosowaniami na tego samego
            "show_vote_counts_before_vote": false,  // Anty-bandwagon
            "show_submitter_username":      false,  // Anty-bias
        },

        # ── Push Notifications ────────────────────────────────────────────────
        "push_notifications": {
            "enabled": true,
            "default_prefs": {
                "quest_expiring":        true,
                "quest_verified":        true,
                "quest_rejected":        true,
                "vote_result":           true,
                "level_up":              true,
                "avatar_evolved":        true,
                "new_follower":          true,
                "comment_on_proof":      true,
                "reaction_on_proof":     false,   // opt-in
                "daily_quest_reminder":  false,   // opt-in
                "streak_warning":        true,
                "leaderboard_overtaken": false,   // opt-in
            },
            "rate_limits": {
                "max_per_hour":       5,
                "max_per_day":       20,
                "quiet_hours_start": 23,   # 23:00 lokalna TZ gracza
                "quiet_hours_end":    7,   # 07:00 lokalna TZ gracza
            },
        },

        # ── Avatary — tiery (T1–T20 → przedziały poziomów) ───────────────────
        "avatar_tiers": {
            "T1":  {"min_level": 1,   "max_level": 4},
            "T2":  {"min_level": 5,   "max_level": 9},
            "T3":  {"min_level": 10,  "max_level": 14},
            "T4":  {"min_level": 15,  "max_level": 19},
            "T5":  {"min_level": 20,  "max_level": 24},
            "T6":  {"min_level": 25,  "max_level": 29},
            "T7":  {"min_level": 30,  "max_level": 34},
            "T8":  {"min_level": 35,  "max_level": 39},
            "T9":  {"min_level": 40,  "max_level": 44},
            "T10": {"min_level": 45,  "max_level": 49},
            "T11": {"min_level": 50,  "max_level": 54},
            "T12": {"min_level": 55,  "max_level": 59},
            "T13": {"min_level": 60,  "max_level": 69},
            "T14": {"min_level": 70,  "max_level": 79},
            "T15": {"min_level": 80,  "max_level": 89},
            "T16": {"min_level": 90,  "max_level": 99},
            "T17": {"min_level": 100, "max_level": 104},
            "T18": {"min_level": 105, "max_level": 109},
            "T19": {"min_level": 110, "max_level": 119},
            "T20": {"min_level": 120, "max_level": null},  // null = bez górnego limitu
        },

        # ── Avatar — modele AI per tier ───────────────────────────────────────
        "avatar_models": {
            # Tier 1-15: tańszy Flash (eksperymentalny)
            "default": "gemini-3.1-flash-image-preview",
            # Tier 16-20: jakościowy Pro
            "pro_from_tier": 16,
            "pro_model":   "gemini-3-pro-image-preview",
        },

        # ── Social ─────────────────────────────────────────────────────────────
        "social": {
            "max_following":               1000,
            "comment_max_length":           300,
            # Reakcje — klucz: emoji, label PL, specjalna akcja
            "reaction_types": [
                {"key": "inspiring",  "emoji": "✨", "label": "Inspirujące"},
                {"key": "creative",   "emoji": "💡", "label": "Pomysłowe"},
                {"key": "quiet",      "emoji": "🤫", "label": "Ciche, ale mocne"},
                {"key": "want_try",   "emoji": "🎯", "label": "Chcę spróbować",
                 "action": "clone_quest"},  # Specjalna akcja: klonuje ten quest dla użytkownika
                {"key": "detail",     "emoji": "🔍", "label": "Piękny detal"},
                {"key": "good_energy","emoji": "🌟", "label": "Dobra energia"},
            ],
            "leaderboard_update_interval_h": 1,
            "feed_page_size":               20,
        },

        # ── Proof System ────────────────────────────────────────────────────────
        "proofs": {
            # Które typy proofu są aktywne (testujemy wszystkie, wyłączamy co nie działa)
            "enabled_types":     ["photo", "multi_photo", "video", "audio", "text"],
            "multi_photo_max":   3,        # Max zdjęć w multi_photo proof
            "video_max_seconds": 60,
            "audio_max_seconds": 120,
            "text_min_chars":    50,       # Min długość notatki tekstowej
            "text_max_chars":    1000,
            "default_visibility": "public",
        },

        # ── Token Shop ────────────────────────────────────────────────────────
        # enabled=false na MVP → włączyć przez game_config, zero deployu
        # type: "consumable" = znika po użyciu | "permanent" = na zawsze
        # price: -1 = variable (np. napiwek — kwotę podaje user)
        # Seasonal items zarządzane osobno (tabela seasonal_items, nie tu)
        # Post-MVP items (nie ma w katalogu): custom_quest_proposal (złożony review pipeline)
        "token_shop": {
            "enabled": false,
            "categories": [
                {
                    "key": "utility",
                    "name": "Narzędzia Herosa",
                    "items": [
                        # Niskie ceny — ≤ 1 dzień earnu; decyzja "już teraz" vs "czekam"
                        {"key": "quest_reroll",    "name": "Przerzut Questa",         "price": 5,  "type": "consumable",
                         "description": "Odrzuć aktualnie wygenerowany quest i wylosuj nowy (bez cooldownu)"},
                        {"key": "timer_extension", "name": "Przedłużenie Timera",     "price": 8,  "type": "consumable",
                         "grants_minutes": 10, "description": "+10 minut do aktywnego timera questa (jednorazowo)"},
                        {"key": "proof_retry",     "name": "Ponowna Próba Dowodu",    "price": 15, "type": "consumable",
                         "description": "Dodatkowa próba wysłania dowodu po odrzuceniu (ominięcie cooldownu retry)"},
                        {"key": "streak_shield",   "name": "Tarcza Passy",            "price": 30, "type": "consumable",
                         "description": "Chroni passę przez 1 dzień nieaktywności; max 1 aktywna tarcza naraz"},
                    ],
                },
                {
                    "key": "personalization",
                    "name": "Personalizacja",
                    "items": [
                        # Trwałe odblokowania — znaczące wydatki wymagające przemyślenia
                        {"key": "character_rename",        "name": "Zmiana Imienia",             "price": 50,  "type": "permanent"},
                        {"key": "class_reflect",           "name": "Odbicie Klasy",              "price": 150, "type": "permanent",
                         "description": "Jednorazowa zmiana klasy (atrybuty reset do nowych bazowych; historia questów zachowana)"},
                        {"key": "avatar_refresh",          "name": "Odświeżenie Avatara",        "price": 200, "type": "permanent",
                         "description": "Regeneracja wyglądu avatara poza normalną progresją poziomów"},
                        # Ramki — tańsze niż avatar_refresh; wizualna ekspresja
                        {"key": "frame_neon_basic",        "name": "Neonowa Ramka",              "price": 100, "type": "permanent"},
                        {"key": "frame_cyber_gold",        "name": "Cyber Złoto",                "price": 200, "type": "permanent"},
                        {"key": "frame_mana_swirl",        "name": "Spirala Many",               "price": 400, "type": "permanent"},
                        # Tytuły — prestiż społeczny
                        {"key": "title_urban_ghost",       "name": "Miejski Duch",               "price": 150, "type": "permanent"},
                        {"key": "title_neon_walker",       "name": "Neonowy Wędrowiec",          "price": 250, "type": "permanent"},
                        # Quest Theme Packs — trwałe odblokowanie stylu generowania questów
                        # 150 HERO = ~3 tyg. casual earnu; permanent = brak expired-anxiety
                        {"key": "quest_theme_urban",       "name": "Motywy: Urban Explorer",    "price": 150, "type": "permanent",
                         "description": "Questy urban: graffiti, architektura, hidden gems, night life"},
                        {"key": "quest_theme_nature",      "name": "Motywy: Natura & Ruch",     "price": 150, "type": "permanent",
                         "description": "Questy outdoorowe: parki, woda, wschód/zachód słońca, flora"},
                        {"key": "quest_theme_cyber",       "name": "Motywy: Cyber Dystopia",    "price": 150, "type": "permanent",
                         "description": "Questy w klimacie neonowym: infrastruktura, kontrast, nocna technologia"},
                    ],
                },
                {
                    "key": "social",
                    "name": "Społeczność",
                    "items": [
                        {"key": "guild_creation", "name": "Założenie Gildii",   "price": 500, "type": "permanent",
                         "description": "Jednorazowa opłata za założenie gildii; dalsze zarządzanie free"},
                        {"key": "tip",            "name": "Napiwek",            "price": -1,  "type": "variable",
                         "min_amount": 1, "description": "Wyślij dowolną kwotę HERO innemu graczowi za jego dowód"},
                    ],
                },
                {
                    "key": "seasonal",
                    "name": "Sezonowe Ekskluzywności",
                    # Items wypełniane dynamicznie przez admin panel per sezon (seasonal_items table)
                    # backend weryfikuje czy sezon jest aktywny przed zakupem
                    # Dwa tiery cenowe:
                    #   base (200):     casual ~3 tyg. aktywności — osiągalny od startu sezonu
                    #   prestige (500): active ~3.5 tyg. — rarity flex, nie dla każdego
                    # 800+ HERO → odłożone do Stage 2 (większa baza, wyraźny currency sink)
                    # Przykłady: unikalne ramki, avatary, motywy questów, tytuły sezonowe
                    "tiers": {
                        "base":     {"price": 200},
                        "prestige": {"price": 500},
                    },
                    "items": [],  # Uzupełniane per sezon — nie seedować tu
                },
            ],
        },

        # ── Quest Paths — Ścieżki (wyłączone na MVP) ─────────────────────────────
        "paths": {
            "enabled":            false,
            "max_active_paths":   3,       // Max równoległych ścieżek per user
            "xp_multiplier":      1.2,     // Bonus XP za questy w ramach ścieżki
            "reminder_after_days": 2,      // Push notification jeśli brak aktywności
        },

        # ── Mood-based Quest Generation ──────────────────────────────────────────
        "quest_generation": {
            "mood_options": [
                {"key": "bored",    "label": "Znudzony/a",  "emoji": "😐"},
                {"key": "happy",    "label": "W dobrej formie", "emoji": "😄"},
                {"key": "tired",    "label": "Zmęczony/a",   "emoji": "😴"},
                {"key": "restless", "label": "Mam za dużo energii", "emoji": "⚡"},
                {"key": "calm",     "label": "Spokojny/a",   "emoji": "🧘"},
                {"key": "stressed", "label": "Potrzebuję reset", "emoji": "😤"},
            ],
            "budget_options": [
                {"key": "free",   "label": "Nic nie wydaję"},
                {"key": "low",    "label": "Do 10 zł"},
                {"key": "medium", "label": "Do 50 zł"},
            ],
            "companion_options": [
                {"key": "solo",  "label": "Sam/a"},
                {"key": "duo",   "label": "Z kimś (2 osoby)"},
                {"key": "group", "label": "Z grupą (3+)"},
            ],
            "quest_count_per_generation": 3,  # Ile questów pokazujemy naraz
        },

        # ── Wallet (wyłączony na MVP) ─────────────────────────────────────────
        "wallet_custodial": {
            "enabled": false,
            "phase":   "virtual",  // virtual → blockchain (pomijamy fiat; Polygon TBD)
        },
        "token_withdrawal": {
            "enabled": false,
        },

        # ── Klasy postaci — bazowe atrybuty (używane przez finalize_onboarding) ─
        # Wartości bazowe = 50 dla każdego atrybutu; primary boost z CLASS_ATTRIBUTE_BOOSTS
        # Suma = 250 (5 atrybutów × 50) + 45 punktów boostu = 295 startowych
        "character_classes": {
            "techno_mag":         {"base_attributes": {"sila": 50, "intelekt": 75, "charyzma": 50, "zrecznosc": 50, "percepcja": 70}},
            "chrom_paladin":      {"base_attributes": {"sila": 75, "intelekt": 50, "charyzma": 70, "zrecznosc": 50, "percepcja": 50}},
            "widmo_biegacz":      {"base_attributes": {"sila": 50, "intelekt": 50, "charyzma": 50, "zrecznosc": 75, "percepcja": 70}},
            "bio_szaman":         {"base_attributes": {"sila": 70, "intelekt": 50, "charyzma": 50, "zrecznosc": 50, "percepcja": 75}},
            "inzynier_spoleczny": {"base_attributes": {"sila": 50, "intelekt": 65, "charyzma": 80, "zrecznosc": 50, "percepcja": 50}},
            "kurier_cieni":       {"base_attributes": {"sila": 65, "intelekt": 50, "charyzma": 50, "zrecznosc": 80, "percepcja": 50}},
            "architekt_danych":   {"base_attributes": {"sila": 50, "intelekt": 80, "charyzma": 65, "zrecznosc": 50, "percepcja": 50}},
            "koderyta":           {"base_attributes": {"sila": 50, "intelekt": 70, "charyzma": 70, "zrecznosc": 50, "percepcja": 60}},
        },

        // ── Grupy (wyłączone na MVP — aktywacja post-launch) ──────────────────
        groups: {
          enabled:              false,
          maxMembers:           20,
          groupQuestEnabled:    false,
          groupQuestXpBonus:    50,
          maxGroupsPerUser:     5,
        },
  }

  for (const [key, value] of Object.entries(entries)) {
    await db.insert(gameConfig)
      .values({ key, value })
      .onConflictDoNothing({ target: gameConfig.key })
  }
  console.log(`  game_config: ${Object.keys(entries).length} kluczy zaseedowanych`)
}
```

### 19.4 prompt_templates — klucze i skrócona treść

> Pełne treści promptów są w sekcjach: 4.1 (onboarding), 4b (quest), 4.3 (weryfikacja), 12.6 (avatar).
> Poniżej — struktura seeda. W implementacji wkleić pełną treść z odpowiedniej sekcji.

```typescript
async function seedPromptTemplates() {
  // Klucze muszą pasować do tych używanych w configService.getPrompt()
  const templates = [
    {
      key: 'onboarding',
      // Pełna treść: Sekcja 4.1 → System Prompt (Aldric persona, klasy, JSON rules)
      systemPrompt: `Jesteś Aldric, starym Mistrzem Gildii Dwudziestominutowych Bohaterów...
[PEŁNA TREŚĆ Z SEKCJI 4.1]`,
      // Pełna treść: Sekcja 4.1 → Opening Template (tylko Turn 0)
      userTemplate: `{{ hour < 12 ? 'Dobry poranek' : hour < 18 ? 'Dzień dobry' : 'Dobry wieczór' }},
wędrowcze z {{ city }}...
[PEŁNA TREŚĆ Z SEKCJI 4.1]`,
    },
    {
      key: 'quest_generation',
      // Pełna treść: Sekcja 4b → System Prompt
      systemPrompt: `Jesteś Mistrzem Gry (GM) świata Neon & Mana...
[PEŁNA TREŚĆ Z SEKCJI 4b]`,
      // Pełna treść: Sekcja 4b → User Template (7 sekcji)
      userTemplate: `## POSTAĆ GRACZA
**Klasa:** {{ character.classDisplay }} ({{ character.classKey }})
...
[PEŁNA TREŚĆ Z SEKCJI 4b]`,
    },
    {
      key: 'proof_verification',
      // Pełna treść: Sekcja 4.3 → System Prompt
      systemPrompt: `Jesteś arbitrem sprawiedliwości w świecie Neon & Mana...
[PEŁNA TREŚĆ Z SEKCJI 4.3]`,
      // Pełna treść: Sekcja 4.3 → User Template (z EXIF checks)
      userTemplate: `## QUEST CONTEXT
**Quest:** {{ quest.title }}
...
[PEŁNA TREŚĆ Z SEKCJI 4.3]`,
    },
    {
      key: 'avatar_visual_anchor',
      // Pełna treść: Sekcja 12.6 → generateVisualAnchor() system prompt
      // Jednorazowy call tekstowy przy Tier 1 — generuje distinctiveFeatures + silhouette
      systemPrompt: `Jesteś artystycznym dyrektorem gry RPG Neon & Mana...
[PEŁNA TREŚĆ Z SEKCJI 12.6 — generateVisualAnchor()]`,
      userTemplate: `Klasa: {{ character.classKey }}
Imię: {{ character.name }}
Cechy osobowości: {{ character.traits.join(', ') }}
...
[PEŁNA TREŚĆ Z SEKCJI 12.6]`,
    },
    {
      key: 'avatar_generation',
      // Brak systemPrompt — Gemini image API nie przyjmuje systemInstruction
      // System prompt wbudowany jako preambuła w userTemplate
      systemPrompt: '',
      // Pełna treść: Sekcja 12.6 → generateAvatarImage() prompt template
      userTemplate: `STYL ARTYSTYCZNY: {{ artStyle }}
KLASA: {{ classLoreTagline }}
...
[PEŁNA TREŚĆ Z SEKCJI 12.6]`,
    },
    {
      key: 'content_moderation',
      // Pełna treść: Sekcja 27.3 → system prompt + user template
      systemPrompt: `Jesteś moderatorem treści w grze The 20-Minute Hero.
Twoim zadaniem jest ocena czy przesłany materiał (zdjęcie, audio, tekst lub wideo)
stanowi autentyczny dowód ukończenia questu w świecie rzeczywistym.
Odpowiadaj WYŁĄCZNIE w formacie JSON zgodnym ze schematem ModerationResult.
Bądź rygorystyczny wobec treści niebezpiecznych (przemoc, nagość, hate speech),
ale dawaj benefit of the doubt dla niejednoznacznych autentycznych prób.
[PEŁNA TREŚĆ Z SEKCJI 27.3]`,
      userTemplate: `Typ dowodu: {{ proofType }}
Ocena bezpieczeństwa i autentyczności:
[PEŁNA TREŚĆ Z SEKCJI 27.3]`,
    },
  ]

  for (const t of templates) {
    await db.insert(promptTemplates)
      .values(t)
      .onConflictDoNothing({ target: promptTemplates.key })
  }
  console.log(`  prompt_templates: ${templates.length} szablonów zaseedowanych`)
}
```

### 19.5 Checklist przed uruchomieniem seed.ts

```
□ pnpm drizzle-kit migrate   → tabele istnieją
□ CREATE EXTENSION postgis   → w pierwszej migracji drizzle-kit
□ .env wypełniony            → DATABASE_URL ustawiony
□ pnpm --filter @20hero/db seed  → "✅ Seed zakończony"
□ Weryfikacja:
  SELECT key FROM game_config ORDER BY key;      → ~18 kluczy (xp_per_difficulty, leveling, xp, token_rewards, quests, voting, push_notifications, avatar_tiers, avatar_models, social, proofs, token_shop, paths, quest_generation, wallet_custodial, token_withdrawal, groups, rate_limits.*)
  SELECT key FROM prompt_templates ORDER BY key; → 6 kluczy (onboarding, quest_generation, proof_verification, avatar_visual_anchor, avatar_generation, content_moderation)
  SELECT COUNT(*) FROM users WHERE is_showcase = true; → 3-5 showcase accounts
```

### 19.6 Showcase Proofs — Cold Start Feed

> **Kontekst (cold start problem)**: Nowy gracz otwierający feed social widzi pustą listę.
> Zero proofów = zero momentum = niższy Day-7 retention. Rozwiązanie: team accounts z
> prawdziwymi showcase proofami zaseedowanymi przed startem produkcji.

```typescript
// packages/db/src/seed.ts

async function seedShowcaseProofs() {
  // 1. Stwórz 3-5 "team" accounts z flagą is_showcase=true
  //    (ukryte w normalnym feed; widoczne tylko w onboarding/discovery mode)
  const showcaseUsers = [
    { name: 'Hero Team Warszawa', classKey: 'techno_mag',    city: 'Warszawa' },
    { name: 'Hero Team Kraków',   classKey: 'chrom_paladin', city: 'Kraków'   },
    { name: 'Hero Team Gdańsk',   classKey: 'widmo_biegacz', city: 'Gdańsk'   },
  ]

  for (const u of showcaseUsers) {
    const user = await db.insert(users).values({
      firebaseUid:  `showcase_${u.city.toLowerCase()}`,
      displayName:  u.name,
      isShowcase:   true,   // nowe pole — ukrywa przed zwykłymi filterami
      createdAt:    subDays(now(), 30),  // "stare" konto — wygląda naturalnie
    }).onConflictDoNothing().returning()

    if (!user[0]) continue  // już zaseedowane

    // 2. Każdy showcase user ma 5-10 ukończonych questów z proofami
    //    Proof media: pre-uploadowane do S3 przez team; cdn_url hardkodowane
    await db.insert(proofs).values([
      {
        userId:      user[0].id,
        cdnUrl:      `https://cdn.20hero.app/showcase/${u.city.toLowerCase()}/proof_01.jpg`,
        caption:     'Odkryłem dziś coś zupełnie nowego w swoim mieście 🗺️',
        verifiedAt:  subDays(now(), 25),
        isShowcase:  true,
      },
      // ... więcej proofów
    ])
  }

  console.log(`  showcase_proofs: zaseedowane dla ${showcaseUsers.length} miast`)
}
```

**Kluczowe zasady showcase proofów:**
- `is_showcase = true` → widoczne w "Discovery" feed nowych userów (< 7 dni konta)
- Nie liczą się do leaderboardów ani voting queue
- Regularnie odświeżane przez team (co sezon) żeby feed wyglądał świeżo
- Media uploadowane ręcznie do S3 przed seed.ts — seed.ts tylko tworzy DB records

---

