<!-- PART OF: ARCHITECTURE.md — The 20-Minute Hero Complete Architecture -->
<!-- DOCUMENT: 04-quest-proof-xp-tasks.md -->
<!-- CONTENTS: Quest Discovery, Upload Flow, XP System, Background Tasks, Voting, Push Notifications -->
<!-- SPLIT: lines 3779–5580 of original file -->

## 5. Quest Generation Data Flow

```
Step 1: Client Request
  User opens app → taps "Generate Quest"
  App sends: POST /api/quests/generate
  Body: { lat: 50.0614, lng: 19.9366, available_minutes: 20 }

Step 2: Auth & Guard Checks
  - Verify Firebase ID token → extract userId (middleware requireAuth)
  - Load character → check has active character
  - Check: no other quest in status "accepted" or "in_progress"
  - Check: not on quest generation cooldown (max 3 per hour)

Step 3: Parallel External Data Fetch
  Task A: WeatherService.getCurrent(lat, lng)
    → GET https://api.openweathermap.org/data/2.5/weather?lat=...&appid=...
    → Returns: {condition, tempC, humidity, windKmh, description}

  Task B: GeoService.reverseGeocode(lat, lng)
    → GET https://nominatim.openstreetmap.org/reverse?lat=...&format=json
    → Returns: {displayName, address: {city, suburb, road, amenity}}
    → classifyLocationType(address) → LocationType

  Both run concurrently via Promise.all()

Step 4: Context Assembly
  context = {
    character: {
      class: character.classDisplay,
      level: character.level,
      traits: character.personalityTraits,
      equipment: character.equipment,
      topAttributes: getTopTwoAttributes(character),
    },
    location: {
      type: locationType,            // "urban_street"
      placeName: nominatimResult,    // "Kraków, Stare Miasto"
      lat, lng,
    },
    weather: weatherData,
    timeContext: getTimeContext(),   // "evening_weekday"
    allowedDifficulties: getUnlockedDifficulties(character.level),
    pastQuestTypes: await dedupService.getRecentHashes(userId, { limit: 15 }),
    // Last 15 quest type hashes → AI gets them as "avoid these patterns"
  }

Step 5: Dedup check before AI
  recentHashes = await dedupService.getRecentHashes(userId, { limit: 15 })
  // Passed to prompt as context: "User has already seen these quest types — avoid"

Step 6: Gemini API Call
  prompt    = renderQuestPrompt(context)
  response  = await aiService.generateQuest(prompt)
  questData = response.parsed   // Zod-parsed directly

Step 7: Persist + save to dedup
  quest = await questRepo.create({
    ...questData,
    characterId: character.id,
    userId,
    weatherAtGen: weatherData,
    lat, lng,
    geohash: encodeGeohash(lat, lng, { precision: 6 }),
    xpBase: xpPerDifficulty[questData.difficulty],  // from game_config
    status: "available",
    generationContext: context,
  })

  // Save to dedup history (non-blocking — fire and forget)
  dedupService.record(userId, quest).catch(console.error)

  return questGenerateResponseFromQuest(quest)

Step 8: Client Accepts (POST /api/quests/{id}/accept)
  - Update quest.status = "accepted"
  - Set quest.acceptedAt = new Date()
  - Set quest.expiresAt = new Date(Date.now() + (timerCfg.durationMinutes + timerCfg.gracePeriodMinutes) * 60_000)
  - Schedule expiry check (BullMQ delayed job)

Step 8b: Pre-generation (fire-and-forget, w tle — nie blokuje Accept response)
  // Optymistyczne generowanie następnego questa — żeby był gotowy zanim gracz skończy
  questQueue.add('pregenerate', {
    userId,
    characterId: character.id,
    lat, lng,                   // ostatnia znana lokalizacja z Accept
    triggeredBy: 'quest_accept',
  }, {
    delay: 0,                   // od razu, ale nie blokuje głównego flow
    attempts: 1,                // 1 próba — niepowodzenie nie jest krytyczne
    removeOnComplete: true,
  }).catch(logger.warn)         // fire-and-forget: błąd logujemy, nie rzucamy

  // Worker pregenerate (questQueue):
  // 1. Generuje questa tym samym algorytmem (Steps 1-7)
  // 2. Zapisuje ze status="pregenerated", expires_at = now + 4h
  // 3. Przy następnym /api/quests/generate → sprawdź najpierw pregenerated dla userId
  //    Jeśli istnieje i jest <4h stary → zwróć od razu (0 latency dla gracza)
  //    Jeśli nie istnieje → generuj synchronicznie jak dotychczas
```

---

## 5b. Geospatial — Quest Discovery

### Decyzja: PostGIS (nie Haversine w TypeScript)

PostGIS daje indeksowane zapytania przestrzenne — `ST_DWithin` z indeksem GIST jest rzędy wielkości szybszy niż iterowanie po wszystkich questach w TypeScript.

### Schema — rozszerzenia DDL

```sql
-- Jednorazowo przy inicjalizacji bazy
CREATE EXTENSION IF NOT EXISTS postgis;

-- quest_templates: punkt geograficzny + promień widoczności
ALTER TABLE quest_templates
  ADD COLUMN location          GEOGRAPHY(POINT, 4326),
  ADD COLUMN radius_visible_m  INT NOT NULL DEFAULT 500;
  -- radius_visible_m: user musi być bliżej niż ta wartość żeby quest był widoczny
  -- Przykład: 100m dla "Znajdź fontannę na Rynku", 5000m dla eventu miejskiego

CREATE INDEX idx_quest_templates_location
  ON quest_templates USING GIST (location);

-- users: ostatnia znana pozycja (do personalizacji feeda i streakowego rankingu)
ALTER TABLE users
  ADD COLUMN last_location            GEOGRAPHY(POINT, 4326),
  ADD COLUMN last_location_updated_at TIMESTAMPTZ;
```

### Klasyfikacja lokalizacji — reverse geocoding

Przed generowaniem questa AI dostaje `locationType` i `placeName`.
Używamy **Nominatim (OpenStreetMap)** — darmowy, bez API key, rate limit 1 req/s po stronie backendu.

```typescript
// services/location-service.ts
import { RateLimiter } from '@20hero/utils'

const rateLimiter = new RateLimiter({ minDelayMs: 1000 })

const OSM_TAG_TO_TYPE: Record<string, string> = {
  park:             'park',
  nature_reserve:   'park',
  forest:           'park',
  retail:           'shopping_area',
  commercial:       'shopping_area',
  marketplace:      'shopping_area',
  water:            'waterfront',
  riverbank:        'waterfront',
  beach:            'waterfront',
  museum:           'cultural_site',
  place_of_worship: 'cultural_site',
  university:       'campus',
  residential:      'residential',
  // default → "urban_street"
}

export async function classifyLocation(lat: number, lon: number): Promise<LocationContext> {
  await rateLimiter.wait()
  const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&extratags=1&accept-language=pl`
  const resp = await fetch(url, { headers: { 'User-Agent': '20hero-app/1.0' } })
  const data = await resp.json()

  const extraTags = data.extratags ?? {}
  let locationType = 'urban_street'
  for (const [tag, ltype] of Object.entries(OSM_TAG_TO_TYPE)) {
    if ((extraTags.landuse ?? '').includes(tag) || (extraTags.leisure ?? '').includes(tag)) {
      locationType = ltype
      break
    }
  }

  const addr = data.address ?? {}
  const city = addr.city ?? addr.town ?? addr.village ?? ''

  return {
    placeName:   data.display_name,   // "okolice Rynku Głównego, Kraków, Polska"
    locationType,
    city,
    countryCode: (addr.country_code ?? 'pl').toUpperCase(),
  }
}
```

Wynik trafia jako `locationContext` do szablonu w quest generation (Sekcja 4b).

### Wyszukiwanie questów w pobliżu

```typescript
// repositories/quest-repo.ts
import { db } from '@20hero/db'
import { sql } from 'drizzle-orm'

export async function findNearby(params: {
  lat: number
  lon: number
  userId: string
  userLevel: number
  radiusM?: number
  limit?: number
}): Promise<QuestTemplate[]> {
  /**
   * Questy posortowane po odległości.
   * Promień widoczności decyduje quest (radius_visible_m), parametr radiusM
   * to dodatkowe ograniczenie z poziomu wywołania (dla fallback stepów).
   * Wyklucza questy wykonane przez użytkownika w ciągu ostatnich 7 dni.
   */
  const { lat, lon, userId, userLevel, radiusM = 500, limit = 5 } = params
  const rows = await db.execute(sql`
    SELECT qt.*,
           ST_Distance(qt.location,
                       ST_Point(${lon}, ${lat})::geography) AS distance_m
    FROM quest_templates qt
    WHERE
        qt.is_active = TRUE
        AND ST_DWithin(qt.location,
                       ST_Point(${lon}, ${lat})::geography,
                       LEAST(qt.radius_visible_m, ${radiusM}))
        AND (qt.min_level IS NULL OR qt.min_level <= ${userLevel})
        AND NOT EXISTS (
            SELECT 1 FROM quest_seen qs
            WHERE qs.user_id  = ${userId}
              AND qs.quest_id = qt.id
              AND qs.seen_at  > NOW() - INTERVAL '7 days'
        )
    ORDER BY distance_m ASC
    LIMIT ${limit}
  `)
  return rows.rows as QuestTemplate[]
}
```

### Fallback — brak questów w pobliżu

```typescript
// services/quest-discovery-service.ts

const RADIUS_STEPS_M = [500, 2_000, 10_000]

export async function discoverQuests(params: {
  lat: number
  lon: number
  userId: string
  userLevel: number
}): Promise<QuestTemplate[]> {
  const { lat, lon, userId, userLevel } = params

  for (const radiusM of RADIUS_STEPS_M) {
    const quests = await questRepo.findNearby({ lat, lon, userId, userLevel, radiusM })
    if (quests.length > 0) {
      return quests
    }
  }

  // Żaden radius nie dał wyniku → generyczny quest miejski (nie zapisywany do DB)
  const locationCtx = await locationService.classifyLocation(lat, lon)
  const fallback = await questAiService.generateGeneric(locationCtx, userLevel)
  return [fallback]
}
```

### Aktualizacja lokalizacji gracza

```
POST /api/users/me/location
  Body: { lat: number, lon: number }
  Auth: Bearer token
  → Aktualizuje users.last_location + last_location_updated_at
  → Nie przechowujemy historii lokalizacji (RODO)
```

Kiedy app wysyła lokalizację:
- Otwarcie zakładki "Questy w pobliżu"
- Akceptacja questa (start point — do EXIF match)
- Submisja proofа (end point — do EXIF match)

---

## 6. Photo Verification Flow

```
Phase 1a: Pobierz presigned URL
  POST /api/proofs/upload-url
  - Waliduj quest istnieje i należy do usera
  - Sprawdź upload_limits z game_config (MIME, max_mb)
  - Wygeneruj S3 presigned PUT URL: s3Client.getSignedUrlPromise('putObject', {
        Bucket: S3_BUCKET_RAW, Key: s3Key, ContentType: mimeType,
        Expires: 600
    })
  - Zapisz w Redis: upload:{uploadId} = {s3Key, questId, userId, expiresAt}  TTL=15min
  - Zwróć { uploadUrl, s3Key, uploadId, expiresAt }

Phase 1b: Telefon uploaduje bezpośrednio do S3
  PUT {uploadUrl}  ← 100MB idzie wprost, serwer nie widzi
  Content-Type: image/jpeg
  → 200 OK od S3

Phase 1c: Potwierdź upload + EXIF
  POST /api/proofs/confirm-upload
  - Sprawdź Redis: upload:{uploadId} istnieje i należy do usera
  - Pobierz plik z S3 (headObject — tylko metadane, nie ściągaj)
  - Pobierz pierwsze 64KB (EXIF jest na początku pliku)
  - Extract EXIF:
      exifLat, exifLng = exifData.GPS
      exifCapturedAt = exifData.DateTimeOriginal
  - EXIF checks:
      locationMatch = haversine(exifLat, exifLng, quest.lat, quest.lng) < 500m
      timeMatch = quest.acceptedAt <= exifCapturedAt <= quest.expiresAt
  - Wygeneruj thumbnail async (BullMQ — sharp / ffmpeg)
  - Zwróć { uploadId, thumbnailUrl, exifLocationMatch, exifTimeMatch }

Phase 2: Submit
  POST /api/proofs/{id}/submit
  - Load quest, verify status == "in_progress" or "accepted"
  - Verify quest.userId == current user
  - Verify not already submitted
  - Create proof record (status="pending")
  - Update quest.status = "proof_submitted"
  - Enqueue BullMQ job: proofQueue.add('verify', { proofId })
  - Return { proofId, status: "ai_verifying", etaSeconds: 15 }

Phase 3: AI Verification (background)
  Job: verify (proofQueue)
  - Download image from S3
  - Resize to max 1920px (cost optimization)
  - Call Gemini Flash vision API with quest context
  - Parse verdict: pass | fail | uncertain

  // Thresholds read from game_config "verification_thresholds" — NOT hardcoded:
  //   auto_pass: 0.95  (pass + high confidence → skip community voting)
  //   community_queue: 0.50  (pass + lower confidence → community voting)
  //   auto_fail: 0.30  (fail + high confidence → auto-reject)

  If verdict == "pass" AND confidence >= thresholds.autoPass:
    → proof.status = "ai_verified"
    → Quest.status = "completed"
    → Award XP: xpService.awardQuestXp(character, quest, proof)
    → Copy S3 key to public bucket (hero-proofs-public)
    → Publish to CDN
    → Create feed event

  If verdict == "pass" AND confidence >= thresholds.communityQueue:
    → proof.status = "community_voting"
    → Queue for community vote (Section 8b)

  If verdict == "fail" AND confidence >= thresholds.autoFail:
    → proof.status = "ai_rejected"
    → Quest.status = "failed" (or allow retry, configurable)
    → Notify user with aiReasoning

  If verdict == "uncertain" OR confidence < 0.80:
    → proof.status = "community_voting"
    → Set votingClosesAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
    → Send to public feed for community votes

Phase 4: Community Voting
  POST /api/proofs/{id}/vote
  - Voter must be level ≥ 3 and not the submitter
  - Vote: +1 (approve) or -1 (reject)
  - Award 2 XP to voter immediately
  - Update voteCount, voteApprove, voteReject

  On voteCount reaching threshold OR votingClosesAt:
    voteRatio = voteApprove / voteCount
    If voteRatio >= 0.6 → Accept proof
    If voteRatio < 0.4  → Reject proof
    Else                 → Accept (tie goes to submitter)

    Award bonus XP based on voteRatio:
      bonusXp = Math.floor(proof.quest.xpBonusMax * voteRatio)

    Update proof.finalVoteRatio
    Update quest.status = "completed" or "failed"

Phase 5: XP & Progression
  See Section 7.
```

---

## 6b. Media Upload Flow — Presigned S3 URLs

### Decyzja: presigned URL (nie backend proxy)

```
Backend proxy:  telefon → backend → S3
  ✗ Backend przetwarza każdy bajt wideo (100MB × 1000 userów = problem)
  ✗ Limit timeout Hono (np. 30s) blokuje duże pliki

Presigned URL:  telefon → S3 (bezpośrednio)
  ✓ Backend tylko wystawia URL i weryfikuje po fakcie
  ✓ S3 obsługuje upload niezależnie, bez obciążenia backendu
  ✓ Rozmiar pliku ograniczony po stronie S3 (ContentLengthRange w policy)
```

### Flow krok po kroku

```
1. App: POST /api/proofs/{attemptId}/upload-url
        Body: { fileExtension: "jpg", fileSizeBytes: 4200000 }

2. Backend:
   - Waliduje attemptId (należy do usera, status="in_progress")
   - Waliduje rozszerzenie i rozmiar
   - Generuje s3Key = "proofs/{attemptId}/{uuid}.jpg"
   - Tworzy rekord w quest_proofs (status="pending", s3Key=NULL — upload not yet done)
   - Wywołuje s3.getSignedUrlPromise("putObject", ...)
   → Response: { uploadUrl, s3Key, expiresIn: 600 }

3. App: PUT {uploadUrl}
        Headers: Content-Type: image/jpeg
        Body: raw file bytes
        (bezpośrednio do S3, backend nie uczestniczy)

4. App: POST /api/proofs/{attemptId}/submit
        Body: { s3Key: "proofs/..." }

5. Backend:
   - Weryfikuje że s3Key istnieje w S3 (HEAD request)
   - Aktualizuje quest_proofs.status = "submitted"
   - Uruchamia BullMQ job: proofQueue.add('verify', { proofId })
   → Response: { proofId, status: "processing" }

6. App: GET /api/proofs/{proofId}/status  (polling co 3s)
   → { status: "processing" | "ai_verified" | "community_voting" | "rejected" }
   (lub push notification gdy gotowe — patrz Sekcja 8c)
```

### Implementacja

```typescript
// services/proof-upload-service.ts
import { db } from '@20hero/db'
import { questAttempts, questProofs, proofMedia } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'
import { s3Client } from '../lib/s3'
import { env } from '../lib/env'

// Wszystkie obsługiwane rozszerzenia per proof_type
const ALLOWED_EXTENSIONS: Record<string, Set<string>> = {
  photo:       new Set(['jpg', 'jpeg', 'png', 'heic']),
  multi_photo: new Set(['jpg', 'jpeg', 'png', 'heic']),
  video:       new Set(['mp4', 'mov', 'webm']),
  audio:       new Set(['mp3', 'm4a', 'ogg', 'wav']),
}

const MIME_TYPES: Record<string, string> = {
  jpg: 'image/jpeg',  jpeg: 'image/jpeg',
  png: 'image/png',   heic: 'image/heic',
  mp4: 'video/mp4',   mov: 'video/quicktime',  webm: 'video/webm',
  mp3: 'audio/mpeg',  m4a: 'audio/mp4',
  ogg: 'audio/ogg',   wav: 'audio/wav',
}

const MAX_FILE_SIZE: Record<string, number> = {
  photo:       20  * 1024 * 1024,   // 20 MB na zdjęcie
  multi_photo: 20  * 1024 * 1024,   // 20 MB per zdjęcie (każde osobno)
  video:       200 * 1024 * 1024,   // 200 MB
  audio:       30  * 1024 * 1024,   // 30 MB (120s @ 2Mbps)
}


function makeS3Key(attemptId: string, ext: string, index = 0): string {
  const suffix = index > 0 ? `_${index}` : ''
  return `proofs/${attemptId}/${crypto.randomUUID()}${suffix}.${ext}`
}


export async function createUploadUrl(params: {
  attemptId:      string
  userId:         string
  proofType:      string   // "photo" | "multi_photo" | "video" | "audio"
  fileExtension:  string
  fileSizeBytes:  number
  // For multi_photo:
  photoCount?:      number
  fileSizesBytes?:  number[]
}): Promise<UploadUrlResponse> {
  const { attemptId, userId, proofType, fileExtension, fileSizeBytes, photoCount, fileSizesBytes } = params

  // 1. Walidacja przynależności questa
  const attempt = await db.query.questAttempts.findFirst({ where: eq(questAttempts.id, attemptId) })
  if (!attempt || attempt.userId !== userId || attempt.status !== 'in_progress') {
    throw new ForbiddenError('Nieaktywny quest')
  }

  // 2. Walidacja rozszerzenia
  const ext = fileExtension.toLowerCase().replace(/^\./, '')
  const allowed = ALLOWED_EXTENSIONS[proofType]
  if (!allowed?.has(ext)) {
    throw new ValidationError(`Nieobsługiwane rozszerzenie "${ext}" dla ${proofType}`)
  }

  const maxSize = MAX_FILE_SIZE[proofType]

  if (proofType === 'multi_photo') {
    // Wiele presigned URLs — jedno na zdjęcie (2-3)
    if (!photoCount || !fileSizesBytes) {
      throw new ValidationError('multi_photo wymaga photoCount i fileSizesBytes')
    }
    for (const [i, size] of fileSizesBytes.entries()) {
      if (size > maxSize) {
        throw new ValidationError(`Zdjęcie ${i + 1} za duże. Max: ${maxSize / 1_048_576}MB`)
      }
    }

    const uploadUrls: Array<{ uploadUrl: string; s3Key: string }> = []
    for (let i = 0; i < photoCount; i++) {
      const s3Key = makeS3Key(attemptId, ext, i)
      const uploadUrl = await s3Client.getSignedUrlPromise('putObject', {
        Bucket: env.S3_BUCKET_RAW,
        Key: s3Key,
        ContentType: MIME_TYPES[ext],
        Expires: 600,
      })
      uploadUrls.push({ uploadUrl, s3Key })
    }

    // Zapis pending proof (bez s3Key — będzie podany w submit)
    await db.insert(questProofs).values({
      attemptId, mediaType: 'multi_photo',
      status: 'pending',   // s3Keys still NULL — upload not yet done
    })
    return { uploadUrls, expiresIn: 600 }

  } else {
    // Pojedynczy plik (photo / video / audio)
    if (fileSizeBytes > maxSize) {
      throw new ValidationError(`Plik za duży. Max: ${maxSize / 1_048_576}MB`)
    }

    const s3Key = makeS3Key(attemptId, ext)
    const uploadUrl = await s3Client.getSignedUrlPromise('putObject', {
      Bucket: env.S3_BUCKET_RAW,
      Key: s3Key,
      ContentType: MIME_TYPES[ext],
      Expires: 600,
    })

    await db.insert(questProofs).values({
      attemptId,
      s3Key,
      mediaType: proofType,   // "photo" | "video" | "audio"
      status:    'pending',   // s3Key NULL until submitProof is called
    })
    return { uploadUrl, s3Key, expiresIn: 600 }
  }
}


export async function submitProof(params: {
  attemptId:   string
  userId:      string
  proofType:   string
  visibility?: string
  s3Key?:      string
  s3Keys?:     string[]
  userText?:   string
  userCaption?: string
}): Promise<ProofSubmission> {
  const { attemptId, userId, proofType, visibility = 'public', s3Key, s3Keys, userText, userCaption } = params

  const attempt = await db.query.questAttempts.findFirst({ where: eq(questAttempts.id, attemptId) })
  if (!attempt || attempt.userId !== userId || attempt.status !== 'in_progress') {
    throw new ForbiddenError('Nieaktywny quest')
  }

  let proof: { id: string }

  if (proofType === 'text') {
    // Brak S3 — utwórz proof z treścią tekstową
    if (!userText || userText.trim().length < 50) {
      throw new ValidationError('Tekst musi mieć minimum 50 znaków')
    }
    const [inserted] = await db.insert(questProofs).values({
      attemptId,
      mediaType: 'text',
      userText:  userText.trim(),
      visibility,
      status:    'submitted',
    }).returning()
    proof = inserted

  } else if (proofType === 'multi_photo') {
    if (!s3Keys || s3Keys.length < 2) {
      throw new ValidationError('multi_photo wymaga minimum 2 zdjęcia')
    }
    // Sprawdź wszystkie pliki w S3
    for (const key of s3Keys) {
      if (!(await s3Service.objectExists(key))) {
        throw new ValidationError(`Plik ${key} nie dotarł do serwera`)
      }
    }
    // Pobierz rekord proof (stworzony w createUploadUrl)
    const existing = await db.query.questProofs.findFirst({ where: eq(questProofs.attemptId, attemptId) })
    if (!existing) throw new Error('Proof record not found')
    proof = existing
    // Zapisz pozycje zdjęć w proof_media
    for (const [i, key] of s3Keys.entries()) {
      await db.insert(proofMedia).values({ proofId: proof.id, s3Key: key, sortOrder: i })
    }
    await db.update(questProofs)
      .set({ visibility, userCaption, status: 'submitted' })
      .where(eq(questProofs.id, proof.id))

  } else {
    // photo / video / audio — pojedynczy plik
    if (!s3Key) {
      throw new ValidationError(`${proofType} wymaga s3Key`)
    }
    if (!(await s3Service.objectExists(s3Key))) {
      throw new ValidationError('Plik nie dotarł do serwera — spróbuj ponownie')
    }
    const existing = await db.query.questProofs.findFirst({ where: eq(questProofs.attemptId, attemptId) })
    if (!existing) throw new Error('Proof record not found')
    proof = existing
    await db.update(questProofs)
      .set({ visibility, userCaption, status: 'submitted' })
      .where(eq(questProofs.id, proof.id))
  }

  // Uruchom weryfikację asynchronicznie
  await proofQueue.add('verify', { proofId: proof.id })
  return { proofId: proof.id, status: 'processing' }
}
```

### BullMQ Worker — weryfikacja per typ

```typescript
// workers/proof-worker.ts
import { Worker, Job } from 'bullmq'
import { redis } from '../db/redis'
import { db } from '@20hero/db'
import { questProofs, proofMedia } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'

interface ProofJobData {
  proofId: string
}

new Worker('proof', async (job: Job<ProofJobData>) => {
  const { proofId } = job.data
  const proof = await db.query.questProofs.findFirst({ where: eq(questProofs.id, proofId) })
  if (!proof) throw new Error(`Proof ${proofId} not found`)

  const quest = await questRepo.get(proof.questId)
  const character = await characterRepo.get(proof.characterId)

  // Wspólne dane EXIF (N/A dla text)
  let exif: ExifData | null = null
  let exifLocationMatch: boolean | null = null
  let exifTimeMatch: boolean | null = null

  let result: VerificationResult

  if (proof.mediaType === 'text') {
    // Brak pliku — weryfikacja tylko na podstawie treści tekstowej
    result = await aiService.verifyProof({
      proof, quest, character,
      proofType: 'text',
      mediaBytes: null, mediaMime: null,
    })

  } else if (proof.mediaType === 'multi_photo') {
    // Pobierz wszystkie zdjęcia z proof_media (posortowane)
    const mediaItems = await db.query.proofMedia.findMany({
      where: eq(proofMedia.proofId, proof.id),
      orderBy: (proofMedia, { asc }) => [asc(proofMedia.sortOrder)],
    })
    let imageBytesList = await Promise.all(mediaItems.map(m => s3Service.download(m.s3Key)))
    // Resize każde zdjęcie
    imageBytesList = await Promise.all(imageBytesList.map(b => imageService.resize(b, { maxPx: 1920 })))
    exif = await exifService.extract(imageBytesList[0])  // EXIF z pierwszego
    exifLocationMatch = checkGpsVsQuest(exif, quest)
    exifTimeMatch = checkTimestamp(exif, proof.attempt.acceptedAt)

    result = await aiService.verifyProof({
      proof, quest, character,
      proofType: 'multi_photo',
      mediaItems: imageBytesList,
      exifData: exif,
    })

  } else if (proof.mediaType === 'audio') {
    const fileBytes = await s3Service.download(proof.s3Key!)
    const ext = proof.s3Key!.split('.').pop() ?? 'mp3'
    const mime = MIME_TYPES[ext] ?? 'audio/mpeg'
    result = await aiService.verifyProof({
      proof, quest, character,
      proofType: 'audio',
      mediaBytes: fileBytes, mediaMime: mime,
      exifData: { duration: getAudioDuration(fileBytes) },
    })

  } else {  // "photo" | "video"
    let fileBytes = await s3Service.download(proof.s3Key!)
    const ext = proof.s3Key!.split('.').pop() ?? 'jpg'
    const mime = MIME_TYPES[ext] ?? 'image/jpeg'
    exif = await exifService.extract(fileBytes)
    exifLocationMatch = checkGpsVsQuest(exif, quest)
    exifTimeMatch = checkTimestamp(exif, proof.attempt.acceptedAt)

    if (mime.includes('image')) {
      fileBytes = await imageService.resize(fileBytes, { maxPx: 1920 })
    }

    result = await aiService.verifyProof({
      proof, quest, character,
      proofType: proof.mediaType,
      mediaBytes: fileBytes, mediaMime: mime,
      exifData: exif,
    })
  }

  // Routing po wyniku (Sekcja 4.3)
  await proofService.routeVerification(result, proof, quest, character)

}, {
  connection: redis,
  concurrency: 5,
  limiter: { max: 10, duration: 1000 },
})
```

### S3 Bucket policy — bezpieczeństwo

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::20hero-proofs/*",
      "Condition": {
        "StringNotEquals": { "aws:PrincipalArn": "arn:aws:iam::ACCOUNT:role/20hero-backend" }
      }
    }
  ]
}
```

Bucket: **prywatny** (zero public read). Dostęp tylko przez:
- Presigned PUTs (z backendu, ważne 10min) — upload przez telefon
- IAM Role backendu — download do weryfikacji AI

### Endpointy — podsumowanie

```
POST /api/proofs/{attemptId}/upload-url
  Auth: Bearer token
  Body (photo/video/audio):   { proofType, fileExtension, fileSizeBytes }
  Body (multi_photo):         { proofType: "multi_photo", fileExtension, photoCount,
                                fileSizesBytes: number[] }
  → photo/video/audio: { uploadUrl, s3Key, expiresIn: 600 }
  → multi_photo:       { uploadUrls: [{uploadUrl, s3Key}, ...], expiresIn: 600 }
  Uwaga: dla proofType="text" → pomiń ten krok, idź od razu do submit

POST /api/proofs/{attemptId}/submit
  Auth: Bearer token
  Body (photo/video/audio):   { proofType, s3Key, visibility?, userCaption? }
  Body (multi_photo):         { proofType: "multi_photo", s3Keys: string[],
                                visibility?, userCaption? }
  Body (text):                { proofType: "text", userText: string, visibility? }
  → { proofId: string, status: "processing" }

GET /api/proofs/{proofId}/status
  Auth: Bearer token
  → { status, verdict?, xpAwarded?, tokensAwarded?, userFeedback?,
      completionFeedback? }
```

---

## 7. XP / Leveling System

### Zasada: wszystkie wartości z `game_config`, żadnych hardcode

XP per difficulty, progi levelów, mnożniki streaka — wszystko w tabeli `game_config`.
Zmiana balansu = jeden SQL update, propagacja w max 5 minut bez restartu.

### XP Sources (wartości startowe — zmieniane przez admin panel)

| Event | Skąd wartość |
|-------|-------------|
| Quest completed | `game_config.xp_per_difficulty[difficulty]` |
| Vote bonus | `game_config.xp_bonuses.vote_xp` × liczba upvotów (max `vote_xp_cap`) |
| First quest of day | `game_config.xp_bonuses.first_quest_daily` |
| Streak multiplier | `game_config.xp_bonuses.streak[days]` |
| Class match bonus | `game_config.xp_bonuses.class_match_bonus` × base XP |
| Daily cap | `game_config.xp_bonuses.daily_cap` |

### Level-Up Logic

```typescript
// services/xp-service.ts
import { db } from '@20hero/db'
import { characters, xpLedger } from '@20hero/db/schema'
import { eq, sql } from 'drizzle-orm'

export async function awardQuestXp(params: {
  character: Character
  quest: Quest
  proof: Proof
}): Promise<XPAwardResult> {
  const { character, quest, proof } = params

  // Wszystkie parametry z config — zero hardcode
  const xpCfg      = await configService.get('xp_per_difficulty')
  const bonuses     = await configService.get('xp_bonuses')
  const levelingCfg = await configService.get('leveling')

  const baseXp  = xpCfg[quest.difficulty]
  const voteXp  = Math.min(proof.upvotes * bonuses.vote_xp, bonuses.vote_xp_cap)
  // class match: computed dynamically (no DB column) — questTags contains classKey
  const classMatches = Boolean(quest.questTags && quest.questTags.includes(character.classKey))
  const classXp = classMatches ? Math.floor(baseXp * bonuses.class_match_bonus) : 0

  let subtotal = baseXp + voteXp + classXp

  // Streak multiplier
  const streak     = await getCurrentStreak(character.userId)
  const multiplier = resolveStreakMultiplier(streak, bonuses.streak)
  subtotal         = Math.floor(subtotal * multiplier)

  // First quest of day bonus
  if (await isFirstQuestToday(character.userId)) {
    subtotal += bonuses.first_quest_daily
  }

  // Daily XP cap — pobierz ile już zdobyto dziś
  const earnedToday = await xpLedgerRepo.sumToday(character.userId)
  const remaining   = Math.max(0, bonuses.daily_cap - earnedToday)
  const totalXp     = Math.min(subtotal, remaining)

  const newTotal = character.xpTotal + totalXp

  // idempotency_key = UNIQUE constraint — wstawia raz lub ignoruje duplikat (ON CONFLICT DO NOTHING)
  // Format: "quest_completed:{proofId}" — unikalny per dowód
  const idempotencyKey = `quest_completed:${proof.id}`

  const inserted = await db.insert(xpLedger).values({
    characterId:    character.id,
    eventType:      'quest_completed',
    amount:         totalXp,
    balanceAfter:   newTotal,
    questId:        quest.id,
    proofId:        proof.id,
    idempotencyKey,
    breakdown: {
      base: baseXp, vote: voteXp, class: classXp,
      streakMult: multiplier, capped: subtotal - totalXp,
    },
  }).onConflictDoNothing()    // idempotent — ponowne wywołanie nie podwaja XP

  // Jeśli wstawiono 0 wierszy → duplicate, wróć 0 XP (już przyznano wcześniej)
  if (inserted.rowCount === 0) {
    logger.warn({ proofId: proof.id }, 'XP award idempotency hit — skipped duplicate')
    return { xpEarned: 0, newTotal: character.xpTotal, leveledUp: false, newLevel: character.level }
  }

  await db.update(characters).set({ xpTotal: newTotal }).where(eq(characters.id, character.id))

  const newLevel  = levelForXp(newTotal, levelingCfg.xp_coefficient, levelingCfg.max_level)
  const leveledUp = newLevel > character.level
  if (leveledUp) {
    await db.update(characters).set({ level: newLevel }).where(eq(characters.id, character.id))
  }

  return { xpEarned: totalXp, newTotal, leveledUp, newLevel }
}


export function levelForXp(xp: number, xpCoefficient: number, maxLevel = 120): number {
  /**
   * Zwraca poziom (1-indexed) na podstawie formuły: totalXpForLevel(N) = coeff * N * (N-1).
   *
   * Poziom 1 startowy = 0 XP. Poziom 2 wymaga coeff*2*1 XP, itd.
   * Zawsze zwraca co najmniej 1 (nowy gracz = poziom 1, nie 0).
   */
  let level = 1
  for (let n = 2; n <= maxLevel; n++) {
    if (xp >= xpCoefficient * n * (n - 1)) {
      level = n
    } else {
      break
    }
  }
  return level
}


export function resolveStreakMultiplier(streak: number, cfg: Record<string, number>): number {
  // cfg = {"3": 1.10, "7": 1.20, "30": 1.35} — klucze jako stringi w JSONB
  let multiplier = 1.0
  for (const [daysStr, mult] of Object.entries(cfg).sort((a, b) => Number(a[0]) - Number(b[0]))) {
    if (streak >= Number(daysStr)) {
      multiplier = mult
    }
  }
  return multiplier
}
```

### Difficulty distribution — procentowy, nie hard-lock

```typescript
// services/quest-service.ts

export async function pickDifficulty(playerLevel: number): Promise<string> {
  const weightsCfg = await configService.get('quest_difficulty_weights')

  // Znajdź dwa najbliższe progi i interpoluj liniowo
  const levels = Object.keys(weightsCfg).map(Number).sort((a, b) => a - b)
  const lower  = Math.max(...levels.filter(l => l <= playerLevel), levels[0])
  const upper  = Math.min(...levels.filter(l => l >= playerLevel), levels[levels.length - 1])

  let weights: Record<string, number>
  if (lower === upper) {
    weights = weightsCfg[String(lower)]
  } else {
    const t = (playerLevel - lower) / (upper - lower)
    const wLow = weightsCfg[String(lower)]
    const wUp  = weightsCfg[String(upper)]
    weights = Object.fromEntries(
      Object.keys(wLow).map(k => [k, wLow[k] + t * (wUp[k] - wLow[k])])
    )
  }

  // Losowanie ważone
  const difficulties = Object.keys(weights)
  const probs = difficulties.map(d => weights[d])
  return weightedRandom(difficulties, probs)
}
```

---

## 8. Background Tasks & Worker Architecture

### Podział: inline async vs BullMQ

```
Inline async (route handler, fire-and-forget)  — lekkie operacje, brak retry, czas życia = request
  ✓ Zapis audit logu
  ✓ Aktualizacja lastActiveAt na users
  ✓ Inkrementacja licznika wyświetleń questa
  ✗ NIE: AI calls, S3 upload, push notifications (za wolne, nie ma retry)

BullMQ Worker                                  — ciężkie/async operacje, retry, scheduled
  ✓ Weryfikacja proofów (AI vision call)
  ✓ Generowanie avatarów (AI image call + S3)
  ✓ Push notifications
  ✓ Wszystkie cron joby
  ✓ Rozstrzygnięcia głosowań
```

### Konfiguracja

```typescript
// queues/index.ts
import { Queue } from 'bullmq'
import { redis } from '../db/redis'

export const proofQueue        = new Queue('proof',        { connection: redis })
export const avatarQueue       = new Queue('avatar',       { connection: redis })
export const votingQueue       = new Queue('voting',       { connection: redis })
export const notificationQueue = new Queue('notification', { connection: redis })
export const maintenanceQueue  = new Queue('maintenance',  { connection: redis })
export const moderationQueue   = new Queue('moderation',   { connection: redis })

// Scheduled jobs (replaces Celery beat) — registered at startup
export async function registerScheduledJobs() {
  // Quest maintenance — co 5 minut
  await maintenanceQueue.add('close-expired-quests', {}, {
    repeat: { pattern: '*/5 * * * *' },
    jobId: 'close-expired-quests',
  })

  // Community voting — co 15 minut
  await votingQueue.add('close-expired-votes', {}, {
    repeat: { pattern: '*/15 * * * *' },
    jobId: 'close-expired-votes',
  })

  // Push notifications — co 15 min, sprawdza strefy TZ
  await notificationQueue.add('daily-reminders', {}, {
    repeat: { pattern: '*/15 * * * *' },
    jobId: 'daily-reminders',
  })

  // Streak warnings — co godzinę
  await notificationQueue.add('streak-warnings', {}, {
    repeat: { pattern: '0 * * * *' },
    jobId: 'streak-warnings',
  })

  // Token cleanup — codziennie 03:00 UTC
  await maintenanceQueue.add('expire-stale-device-tokens', {}, {
    repeat: { pattern: '0 3 * * *' },
    jobId: 'expire-stale-device-tokens',
  })
}
```

### Kompletny rejestr zadań BullMQ

| Job | Queue | Trigger | Retry | Opis |
|------|-------|---------|-------|------|
| `verify` | `proof` | `POST /api/proofs/{id}/submit` | 3x, exp backoff | AI vision call → route do community voting lub auto-complete |
| `generate-avatar-tier` | `avatar` | level-up crossing tier threshold | 2x | AI image gen + S3 upload + atomic swap isCurrent |
| `generate-visual-anchor` | `avatar` | pierwsze wygenerowanie avatara (Tier 1) | 2x | Text call → JSON z distinctiveFeatures + silhouette |
| `send-quest-warning` | `notification` | `acceptQuest()` → delay=expiresAt-5min | 1x | Push "quest wygasa za 5 minut" |
| `daily-reminders` | `notification` | Cron co 15min | 0 | Znajdź użytkowników których lokalna godz = 09:00 |
| `streak-warnings` | `notification` | Cron co 1h | 0 | Streak wygasa za <24h |
| `close-expired-quests` | `maintenance` | Cron co 5min | 0 | `UPDATE quest_attempts SET status='expired'` |
| `close-expired-votes` | `voting`      | Cron co 15min | 0 | `resolveVote()` dla votingClosesAt < NOW() |
| `expire-stale-device-tokens` | `maintenance` | Cron codziennie 03:00 | 0 | Usuń tokeny z lastUsedAt > 90 dni |

### Retry strategy

```typescript
// workers/proof-worker.ts
import { Worker, Job, UnrecoverableError } from 'bullmq'
import { redis } from '../db/redis'

new Worker('proof', async (job: Job<{ proofId: string }>) => {
  try {
    const result = await proofVerificationService.run(job.data.proofId)
    await proofService.routeVerificationResult(job.data.proofId, result)
  } catch (err) {
    if (err instanceof GeminiRateLimitError) {
      // Rate limit → czekaj 2min (handled by BullMQ delay on retry)
      throw err  // BullMQ will retry with backoff
    }
    throw err
  }
}, {
  connection: redis,
  concurrency: 5,
  limiter: { max: 10, duration: 1000 },
  defaultJobOptions: {
    attempts: 3,
    backoff: {
      type: 'exponential',
      delay: 60_000,   // 1min, 2min, 4min
    },
  },
})
```

---

## 8b. Community Voting System — Pełny Design

### Zasada działania

```
AI → COMMUNITY_VOTING
  ↓
Proof pojawia się w "Kolejce Głosowań" dla innych graczy
  ↓
Gracze (level ≥ 3) głosują: ✅ Zatwierdź / ❌ Odrzuć
  ↓
Na każdym głosie: sprawdź czy quorum + próg osiągnięty (fast-resolve)
  ↓
Po votingClosesAt: wymuszony rozwiązanie (BullMQ cron)
  ↓
voteRatio ≥ 0.60  → Zaakceptowany: XP dla submitter + bonus
voteRatio ≤ 0.35  → Odrzucony: 0 XP, quest "failed", retry opcjonalnie
         (0.35, 0.60) → Remis → Zatwierdź z 0 bonusem (benefit of the doubt)
```

### game_config — parametry głosowania

```sql
INSERT INTO game_config (key, value, description) VALUES
('voting', '{
  "voter_min_level":           3,
  "min_votes":                 3,
  "min_votes_fast_resolve":    5,
  "fast_resolve_ratio":        0.85,
  "voting_duration_hours":     24,
  "approve_threshold":         0.60,
  "reject_threshold":          0.35,
  "tie_outcome":               "approve",
  "zero_vote_outcome":         "rejected",  // domyślnie odrzucone — ochrona przed farmowaniem w niskim ruchu

  "voter_immediate_xp":        5,
  "voter_accuracy_bonus_xp":   5,
  "voter_daily_xp_cap":        100,
  "vote_same_user_cooldown_h": 1,

  "submitter_base_xp_on_queue": true,   // true = award base_xp immediately when proof enters voting queue
                                          // false = award base_xp only after community accepts (safer, stricter)
  "xp_bonus_ratio_multiplier":  1.0,

  "show_vote_counts_before_vote": false,
  "show_submitter_username":      false,
  "allow_vote_comment":           true,
  "vote_comment_max_chars":       140
}', 'Parametry głosowania społeczności. Wszystko hot-reloadable.');
```

> **`show_vote_counts_before_vote: false`** — kluczowe. Ukrywamy aktualne wyniki przed zagłosowaniem. Zapobiega efektowi owczego stada (bandwagon bias).
> **`show_submitter_username: false`** — głosuje na dowód, nie na osobę. Po oddaniu głosu — wszystko widoczne.

---

### Kto może głosować — eligibility

```typescript
// services/voting-service.ts
import { db } from '@20hero/db'
import { characters } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'

export async function checkVoterEligibility(params: {
  voter: User
  proof: Proof
  votingCfg: VotingConfig
}): Promise<{ canVote: boolean; reason: string }> {
  const { voter, proof, votingCfg } = params

  // 1. Nie własny dowód
  if (voter.id === proof.userId) {
    return { canVote: false, reason: 'Nie możesz głosować na własny dowód' }
  }

  // 2. Minimalny level
  const char = await db.query.characters.findFirst({
    where: eq(characters.userId, voter.id),
  })
  if (!char || char.level < votingCfg.voter_min_level) {
    return { canVote: false, reason: `Wymagany level ${votingCfg.voter_min_level}` }
  }

  // 2b. Minimalny wiek konta votera (anty-sybil)
  const voterAccount = await db.query.users.findFirst({ where: eq(users.id, voter.id) })
  const accountAgeMs = Date.now() - new Date(voterAccount!.createdAt).getTime()
  const minAgeMs = (votingCfg.voter_min_account_age_days ?? 3) * 86_400_000
  if (accountAgeMs < minAgeMs) {
    return { canVote: false, reason: 'Konto zbyt nowe aby głosować' }
  }

  // 3. Już zagłosował
  const existing = await voteRepo.get(proof.id, voter.id)
  if (existing) {
    return { canVote: false, reason: 'Już oddałeś głos na ten dowód' }
  }

  // 4. Wykluczenie social graph — follower lub wspólna grupa mogą wpływać stronniczo
  // (P1 — anty-collusion: nie pozwól znajomym podbijać nawzajem swoich proofów)
  if (votingCfg.exclude_social_graph ?? true) {
    const isFollowing = await socialRepo.isFollowing(voter.id, proof.userId)
    if (isFollowing) {
      return { canVote: false, reason: 'Nie możesz głosować na dowody osób które obserwujesz' }
    }
    const sharedGroup = await groupRepo.hasSharedGroup(voter.id, proof.userId)
    if (sharedGroup) {
      return { canVote: false, reason: 'Nie możesz głosować na dowody osób z Twojej grupy' }
    }
  }

  // 5. Cooldown na głosowanie na dowody tego samego użytkownika
  const cooldownH = votingCfg.vote_same_user_cooldown_h
  const since = new Date(Date.now() - cooldownH * 60 * 60 * 1000)
  const recent = await voteRepo.countRecentForUser(voter.id, proof.userId, since)
  if (recent >= 3) {  // max 3 głosy na dowody tego samego usera w ciągu cooldownH
    return { canVote: false, reason: 'Cooldown: zbyt wiele głosów na dowody tej osoby' }
  }

  // 6. Daily XP cap
  const dailyXpEarned = await xpLedgerRepo.sumTodayByType(voter.id, 'vote_cast')
  if (dailyXpEarned >= votingCfg.voter_daily_xp_cap) {
    return { canVote: false, reason: 'Dzienny limit XP z głosowania osiągnięty' }
  }

  return { canVote: true, reason: 'OK' }
}
```

---

### Vote Feed — kolejka głosowań

```typescript
// GET /api/proofs/vote-queue?limit=10&lat=...&lng=...

export async function getVoteQueue(params: {
  voter: User
  lat?: number
  lng?: number
  limit?: number
}): Promise<VoteQueueItem[]> {
  const { voter, lat, lng, limit = 10 } = params
  const votingCfg = await configService.get('voting')

  // Proofs w community_voting których voter nie widział
  // Priorytety:
  //   1. votingClosesAt ASC (najpilniejsze — wkrótce wygasają)
  //   2. Proofs z questami w pobliżu (jeśli GPS dostępne)
  //   3. Proofs od followowanych userów (bardziej zaangażowani)

  const geohashPrefix = lat !== undefined ? encodeGeohash(lat, lng!, { precision: 4 }) : null

  const rows = await db.execute(sql`
    SELECT
        p.id, p.cdn_url, p.thumbnail_url, p.media_type, p.voting_closes_at,
        p.vote_count, p.user_caption,
        q.title AS quest_title, q.objective, q.completion_hint,
        q.difficulty, q.place_name, q.geohash,
        c.name AS char_name, c.class_key AS char_class, c.level AS char_level,
        ca.thumbnail_url AS char_avatar_url, ca.level_tier AS char_tier
    FROM proofs p
    JOIN quests q ON p.quest_id = q.id
    JOIN characters c ON p.character_id = c.id
    LEFT JOIN character_avatars ca ON ca.character_id = c.id AND ca.is_current = TRUE
    LEFT JOIN votes v ON v.proof_id = p.id AND v.voter_user_id = ${voter.id}
    WHERE p.status = 'community_voting'
      AND v.id IS NULL                          -- jeszcze nie głosował
      AND p.user_id != ${voter.id}              -- nie własne
      AND c.level >= 1                          -- sanity check
    ORDER BY
        CASE WHEN ${geohashPrefix} IS NOT NULL AND q.geohash LIKE ${geohashPrefix ? geohashPrefix + '%' : null} THEN 0 ELSE 1 END,
        p.voting_closes_at ASC,                 -- urgency
        p.vote_count ASC                        -- least voted
    LIMIT ${limit}
  `)

  return rows.rows.map(r => voteQueueItemFromRow(r, { showSubmitter: false }))
}


interface VoteQueueItem {
  proofId:          string
  proofType:        string              // "photo"|"multi_photo"|"video"|"audio"|"text"
  mediaUrl:         string | null       // CDN URL (null dla text proofs)
  mediaUrls:        string[] | null     // multi_photo — lista URLs
  thumbnailUrl:     string | null
  mediaType:        string | null
  userText:         string | null       // Tekst do oceny (text proofs)
  userCaption:      string | null       // Opcjonalny komentarz autora
  votingClosesAt:   Date
  minutesRemaining: number              // obliczone po stronie serwera
  distanceM:        number | null       // odległość od lokalizacji gracza

  questTitle:            string
  questObjective:        string
  questCompletionHint:   string        // KLUCZOWE — voter musi wiedzieć co sprawdzić
  questDifficulty:       string
  questPlaceName:        string

  // Postać — NIE username (bias prevention)
  charClassDisplay: string             // np. "Techno-Mag"
  charLevel:        number
  charAvatarThumb:  string | null
  charTier:         number
}
```

---

### Oddanie głosu

```typescript
// POST /api/proofs/{proofId}/vote
// Body: { vote: 1 | -1, comment?: string }

export async function castVote(params: {
  voter:      User
  proofId:    string
  voteValue:  1 | -1
  comment?:   string
}): Promise<VoteCastResult> {
  const { voter, proofId, voteValue, comment } = params
  const votingCfg = await configService.get('voting')
  const proof     = await proofRepo.get(proofId)

  const { canVote, reason } = await checkVoterEligibility({ voter, proof, votingCfg })
  if (!canVote) throw new ForbiddenError(reason)

  // Atomic: zapisz głos + update counters
  let vote: Vote
  await db.transaction(async (tx) => {
    const [inserted] = await tx.insert(votes).values({
      proofId,
      voterUserId:      voter.id,
      voterCharacterId: (await characterRepo.getActive(voter.id)).id,
      vote:             voteValue,
      comment: comment ? comment.slice(0, votingCfg.vote_comment_max_chars) : null,
    }).returning()
    vote = inserted

    // Denormalizowane countery w proofs (szybkie odczyty)
    if (voteValue === 1) {
      await tx.update(proofs).set({ voteApprove: sql`vote_approve + 1` }).where(eq(proofs.id, proofId))
    } else {
      await tx.update(proofs).set({ voteReject: sql`vote_reject + 1` }).where(eq(proofs.id, proofId))
    }
    await tx.update(proofs).set({ voteCount: sql`vote_count + 1` }).where(eq(proofs.id, proofId))

    // Natychmiastowe XP dla votera
    const immediateXp = votingCfg.voter_immediate_xp
    await xpService.grant(tx, voter.id, immediateXp, { eventType: 'vote_cast', voteId: vote!.id })
  })

  // Sprawdź czy możliwy fast-resolve (poza transakcją)
  const updatedProof = await proofRepo.get(proofId)
  await maybeFastResolve(updatedProof, votingCfg)

  return {
    voteId:       vote!.id,
    xpEarned:     votingCfg.voter_immediate_xp,
    currentRatio: null,    // ukryty do końca głosowania jeśli showVoteCounts=false
    message:      'Głos oddany. Wynik poznasz po zakończeniu głosowania.',
  }
}
```

---

### Algorytm rozstrzygnięcia

```typescript
// services/voting-service.ts

export async function maybeFastResolve(proof: Proof, cfg: VotingConfig): Promise<boolean> {
  // Fast-resolve jeśli quorum osiągnięte i wynik jednoznaczny
  if (proof.voteCount < cfg.min_votes_fast_resolve) return false

  const ratio = proof.voteApprove / proof.voteCount
  const fastThreshold = cfg.fast_resolve_ratio  // 0.85

  if (ratio >= fastThreshold || ratio <= (1 - fastThreshold)) {
    await resolveVote(proof.id)
    return true
  }
  return false
}


export async function resolveVote(proofId: string): Promise<VoteResolution> {
  // Finalizuje głosowanie. Wywołane przez cron LUB fast-resolve
  const proof     = await proofRepo.get(proofId)
  const quest     = await questRepo.get(proof.questId)
  const character = await characterRepo.get(proof.characterId)
  const votingCfg = await configService.get('voting')

  let ratio: number
  if (proof.voteCount === 0) {
    // Nikt nie zagłosował przed deadlinem
    // zero_vote_outcome: 'rejected' (domyślnie) = ochrona przed farmowaniem tokenów w niskim ruchu
    //                    'approve'              = benefit of the doubt (tylko po ręcznej zmianie config)
    ratio = votingCfg.zero_vote_outcome === 'approve' ? votingCfg.approve_threshold : 0
  } else {
    ratio = proof.voteApprove / proof.voteCount
  }

  const approveT = votingCfg.approve_threshold  // 0.60
  const rejectT  = votingCfg.reject_threshold   // 0.35

  // Wyznacz wynik
  let outcome: 'accepted' | 'rejected'
  if (ratio >= approveT) {
    outcome = 'accepted'
  } else if (ratio <= rejectT) {
    outcome = 'rejected'
  } else {
    // Remis — wynik z config
    outcome = votingCfg.tie_outcome === 'approve' ? 'accepted' : 'rejected'
  }

  let bonusXp = 0
  await db.transaction(async (tx) => {
    await tx.update(proofs).set({
      status:         outcome,
      finalVoteRatio: ratio,
    }).where(eq(proofs.id, proofId))

    if (outcome === 'accepted') {
      // XP bonus dla submitter (proporcjonalny do voteRatio)
      bonusXp = Math.floor(quest.xpBonusMax * ratio * votingCfg.xp_bonus_ratio_multiplier)

      // Jeśli submitter_base_xp_on_queue=false → base_xp NIE był przyznany wcześniej
      // (gdy true → był przyznany już w routeVerification() przy wejściu do kolejki)
      if (!votingCfg.submitter_base_xp_on_queue) {
        const baseXp = (await configService.get('xp_per_difficulty'))[quest.difficulty]
        await xpService.grant(tx, proof.userId, baseXp, { eventType: 'quest_completed', proofId })
      }

      await xpService.grant(tx, proof.userId, bonusXp, {
        eventType: 'community_vote_received',
        proofId,
      })

      // Token reward jeśli nie przyznano wcześniej
      await tokenService.awardQuestTokens(tx, quest, proof, character)

      await tx.update(quests).set({ status: 'completed' }).where(eq(quests.id, proof.questId))

    } else {  // rejected
      await tx.update(quests).set({ status: 'failed' }).where(eq(quests.id, proof.questId))
      // Push notification do submitter
      await pushService.notify(proof.userId, {
        notificationType: 'vote_result_rejected',
        title: 'Dowód odrzucony',
        body: 'Społeczność Gildii nie zaakceptowała twojego dowodu. Możesz spróbować ponownie.',
        data: { questId: proof.questId },
      })
    }

    // Accuracy bonus dla voterów którzy głosowali z większością
    const majorityVote = outcome === 'accepted' ? 1 : -1
    await awardVoterAccuracyBonuses(tx, proofId, majorityVote, votingCfg)
  })

  return { outcome, ratio, bonusXp }
}


async function awardVoterAccuracyBonuses(
  tx: DbTransaction,
  proofId: string,
  majorityVote: number,
  cfg: VotingConfig,
): Promise<void> {
  // Accuracy bonus dla voterów którzy zagłosowali zgodnie z wynikiem
  const allVotes = await voteRepo.getAllForProof(proofId)
  const accuracyXp = cfg.voter_accuracy_bonus_xp  // 5 XP

  for (const v of allVotes) {
    if (v.vote === majorityVote) {
      await xpService.grant(tx, v.voterUserId, accuracyXp, {
        eventType: 'vote_cast',   // ta sama kategoria co immediate XP
        voteId:    v.id,
        note:      'accuracy_bonus',
      })
    }
  }
}
```

---

### Diagram przepływu XP

```
Submitter wysyła proof
  ↓
AI → COMMUNITY_VOTING
  │
  ├── (jeśli submitter_base_xp_on_queue=true)
  │     → Base XP przyznane od razu: "quest w trakcie weryfikacji"
  │     → Gracz może grać dalej nie czekając na wynik
  │
Voter zagłosuje
  → +5 XP (voter_immediate_xp) — natychmiast
  → Nikt nie wie jak głosowała większość

Rozstrzygnięcie:
  ├── ACCEPTED
  │     Submitter: +bonusXp = xpBonusMax × voteRatio
  │         (np. voteRatio=0.8 × bonusMax=50 = 40 XP bonus)
  │     Voterzy zgodni z wynikiem: +5 XP (accuracy_bonus)
  │     Tokens: quest_reward przyznany
  │
  └── REJECTED
        Submitter: 0 bonus XP; base_xp NIE jest cofany jeśli już przyznany (on_queue=true)
                   ← XP nigdy nie jest odbierany; gracz po prostu nie dostaje bonusXp
        Voterzy niezgodni: 0 accuracy_bonus (tylko immediate XP zostaje)
        Quest: status="failed", retry możliwy (configurable)
```

---

### BullMQ Worker — rozstrzygnięcia po terminie

```typescript
// workers/voting-worker.ts
import { Worker, Job } from 'bullmq'
import { redis } from '../db/redis'
import { db } from '@20hero/db'
import { proofs } from '@20hero/db/schema'
import { eq, lte, sql } from 'drizzle-orm'
import { votingService } from '../services/voting-service'
import { logger } from '../lib/logger'

new Worker('voting', async (job: Job) => {
  if (job.name === 'close-expired-votes') {
    // Uruchamiany co 15 minut. Rozstrzyga głosowania po deadline.
    const expired = await db
      .select({ id: proofs.id })
      .from(proofs)
      .where(
        sql`${proofs.status} = 'community_voting' AND ${proofs.votingClosesAt} <= NOW()`
      )
      .limit(50)  // batch, nie wszystkie naraz

    for (const row of expired) {
      try {
        await votingService.resolveVote(row.id)
      } catch (err) {
        logger.error({ proofId: row.id, err }, 'Failed to resolve vote')
        // Nie re-throw — inne proofs muszą być przetworzone
      }
    }
  }
}, { connection: redis })
```

---

### API Endpoints

```
# Vote Queue
GET  /api/proofs/vote-queue?limit=10&lat=...&lng=...
     → lista VoteQueueItem (bez voteCounts, bez username)

# Cast Vote
POST /api/proofs/{proofId}/vote
     Body: { vote: 1 | -1, comment?: string }
     → { voteId, xpEarned, message }

# Vote Result (po zagłosowaniu)
GET  /api/proofs/{proofId}/vote-result
     → { outcome, finalVoteRatio, votes: [{charLevel, charClass, vote, comment}] }
     (tylko po resolution lub po oddaniu własnego głosu)

# Admin
GET  /api/admin/votes/stats          → { pendingCount, avgResolutionTime, accuracyRates }
POST /api/admin/proofs/{id}/override → Force accept/reject (admin only)
```

---

### Anti-gaming — pełna lista zabezpieczeń

| Wektory ataku | Zabezpieczenie |
|---|---|
| Vote na własny dowód | `voter.id !== proof.userId` — hard check |
| Stworzenie kont-widmo do głosowania | `voter_min_level: 3` — trzeba najpierw grać |
| Vote farming (głosuj wszystko szybko) | `voter_daily_xp_cap: 100` — limit XP z głosowań |
| Koordynowane głosowanie (znajomi approve wszystko) | `vote_same_user_cooldown_h: 1` × max 3 głosy |
| Bandwagon (głosuj jak wszyscy) | `show_vote_counts_before_vote: false` |
| Głosowanie na osobę nie dowód | `show_submitter_username: false` |
| Wysyłanie dowodów w kółko (retry farming) | `game_config.retry_cooldown_hours` + rate limit questów |
| Zorganizowane odrzucanie (toksyczna soc.) | `tie_outcome: "approve"` + min_votes=3 gwarantuje fairness |

---

### Rozwiązanie edge case: Nikt nie zagłosował

```typescript
// Proof wygasł z voteCount = 0

if (proof.voteCount === 0) {
  // Nikt nie zagłosował przed deadlinem
  // zero_vote_outcome z game_config — domyślnie 'rejected' (ochrona przed farmowaniem)
  ratio = votingCfg.zero_vote_outcome === 'approve' ? votingCfg.approve_threshold : 0
  bonusXp = votingCfg.zero_vote_outcome === 'approve' ? Math.floor(quest.xpBonusMax * 0.60) : 0
}
```

---

## 8c. Push Notifications — Kiedy i Co Wysyłać

### 8c.1 Provider i architektura

**Firebase Cloud Messaging (FCM)** — jeden kanał dla Android i iOS.

- Android: FCM → GCM → urządzenie
- iOS: FCM → APNs → urządzenie
- Backend: `firebase-admin` npm package, `sendEachForMulticast()` dla batch
- Token: FCM Registration Token (`device_tokens` JSONB na users, odnawiany przez app co 30 dni)

```typescript
// Format jednego tokenu w users.device_tokens JSON array
{
  token: "fcm_token_string",
  platform: "android" | "ios",
  appVersion: "1.2.3",
  registeredAt: "2026-01-15T10:00:00Z",
  lastUsedAt: "2026-03-09T08:00:00Z"
}
```

Użytkownik może mieć wiele urządzeń — wysyłamy do WSZYSTKICH aktywnych tokenów.

### 8c.2 Schema — preferencje powiadomień

```sql
-- Dodaj kolumnę do users (migracja)
ALTER TABLE users
  ADD COLUMN notification_prefs JSONB NOT NULL DEFAULT '{}'::JSONB;
```

Domyślna wartość gdy `notification_prefs` = `{}` → pobierana z `game_config`:

```json
// game_config key: "push_notifications"
{
  "enabled": true,
  "default_prefs": {
    "quest_expiring":       true,   // Quest wygasa za 5 min
    "quest_verified":       true,   // AI auto-weryfikacja passed
    "quest_rejected":       true,   // Proof odrzucony
    "vote_result":          true,   // Wynik głosowania
    "level_up":             true,   // Nowy level
    "avatar_evolved":       true,   // Nowy avatar gotowy
    "new_follower":         true,   // Ktoś cię obserwuje
    "comment_on_proof":     true,   // Komentarz pod twoim proof
    "reaction_on_proof":    false,  // Reakcja pod proofem (opt-in, nie spam)
    "daily_quest_reminder": false,  // Codzienny reminder (opt-in)
    "streak_warning":       true,   // Zagrożony streak (24h przed utratą)
    "leaderboard_overtaken":false   // Ktoś cię wyprzedził w top 10
  },
  "rate_limits": {
    "max_per_hour": 5,
    "max_per_day": 20,
    "quiet_hours_start": 23,        // 23:00 lokalna strefa gracza
    "quiet_hours_end": 7            // 07:00 lokalna strefa gracza
  }
}
```

```typescript
// services/push-prefs-service.ts

export async function getPrefs(userId: string): Promise<Record<string, boolean>> {
  // Merge default_prefs z game_config + user overrides
  const user = await usersRepo.get(userId)
  const cfgDefaults = (await configService.get('push_notifications')).default_prefs
  return { ...cfgDefaults, ...user.notificationPrefs }
}

export async function isEnabled(userId: string, notificationType: string): Promise<boolean> {
  const prefs = await getPrefs(userId)
  return prefs[notificationType] ?? false
}
```

### 8c.3 Wszystkie typy powiadomień

| Typ | Trigger | Tytuł | Ciało | Deep link |
|-----|---------|-------|-------|-----------|
| `quest_expiring` | BullMQ delayed job = quest.expiresAt - 5min | "Quest wygasa!" | "Masz 5 minut na ukończenie: *{questTitle}*" | `/quest/{questId}` |
| `quest_verified` | AI auto-complete (confidence >= 0.95) | "Quest zaliczony!" | "Zdobywasz {xp} XP i {tokens} HERO za *{questTitle}*" | `/profile` |
| `quest_rejected` | AI reject (confidence >= 0.82) | "Proof odrzucony" | "*{questTitle}* — {aiFeedback}" | `/quest/{questId}/retry` |
| `vote_result_approved` | `resolveVote()` → accepted | "Społeczność zatwierdziła!" | "Zdobywasz {xp} XP za *{questTitle}*" | `/profile` |
| `vote_result_rejected` | `resolveVote()` → rejected | "Proof odrzucony przez głosowanie" | "Spróbuj jeszcze raz: *{questTitle}*" | `/quest/{questId}/retry` |
| `level_up` | `xpService.award()` → new level | "Level {level}!" | "Osiągnąłeś level {level}! {xpToNext} XP do następnego." | `/character` |
| `avatar_evolved` | BullMQ avatar job → complete | "Nowy avatar gotowy!" | "Twój {className} ewoluował do Tier {tier}!" | `/api/characters/me/avatar` |
| `new_follower` | `followService.follow()` | "Nowy obserwujący" | "*{username}* zaczął cię obserwować" | `/users/{followerId}` |
| `comment_on_proof` | `commentsRepo.insert()` | "Nowy komentarz" | "*{commenterUsername}*: \"{commentPreview}\"" | `/proof/{proofId}` |
| `reaction_on_proof` | `reactionsRepo.insert()` | "Reakcja na twój proof" | "*{reactorUsername}* polubił twoje zaliczenie questa" | `/proof/{proofId}` |
| `daily_quest_reminder` | BullMQ cron 09:00 lokalna | "Czas na quest!" | "Co dzisiaj zdobędziesz, {characterName}?" | `/quests/nearby` |
| `streak_warning` | BullMQ cron — streak expires in 24h | "Uwaga! Streak zagrożony" | "Masz {hours}h na zaliczenie questa, żeby utrzymać passę {streakDays} dni" | `/quests/nearby` |
| `leaderboard_overtaken` | `leaderboardService.update()` | "Wyprzedzono cię!" | "*{overtakerUsername}* zajął twoje miejsce #{position} w rankingu" | `/leaderboard` |

### 8c.4 Implementacja — `push-service.ts`

```typescript
// services/push-service.ts
import admin from 'firebase-admin'
import { redis } from '../db/redis'

let firebaseApp: admin.app.App | null = null

export function initFirebase(credentialsPath: string): void {
  const creds = admin.credential.cert(credentialsPath)
  firebaseApp = admin.initializeApp({ credential: creds })
}

const CRITICAL_TYPES = new Set(['quest_expiring'])

export async function notify(userId: string, params: {
  notificationType: string
  title: string
  body: string
  data?: Record<string, string>
}): Promise<void> {
  const { notificationType, title, body, data } = params

  // 1. Sprawdź preferencje
  if (!(await pushPrefsService.isEnabled(userId, notificationType))) return

  // 2. Rate limiting
  if (!(await checkRateLimit(userId))) return

  // 3. Quiet hours check
  if (await inQuietHours(userId)) {
    // Nie wysyłamy, chyba że krytyczne (quest_expiring)
    if (!CRITICAL_TYPES.has(notificationType)) return
  }

  // 4. Pobierz tokeny
  const user = await usersRepo.get(userId)
  const tokens: string[] = user.deviceTokens.map((t: { token: string }) => t.token)
  if (tokens.length === 0) return

  // 5. Wyślij batch via MulticastMessage
  const message: admin.messaging.MulticastMessage = {
    tokens,
    notification: { title, body },
    data: { type: notificationType, ...(data ?? {}) },
    android: { priority: 'high' },
    apns: {
      payload: {
        aps: { sound: 'default', badge: 1 },
      },
    },
  }

  const response = await admin.messaging(firebaseApp!).sendEachForMulticast(message)

  // 6. Wyczyść stale tokeny (które zwróciły błąd rejestracji)
  await cleanupStaleTokens(userId, tokens, response)
}


async function checkRateLimit(userId: string): Promise<boolean> {
  const cfg = (await configService.get('push_notifications')).rate_limits
  const now = new Date()
  const hourKey = `push:rate:hour:${userId}:${now.toISOString().slice(0, 13)}`
  const dayKey  = `push:rate:day:${userId}:${now.toISOString().slice(0, 10)}`

  const hourCount = await redis.incr(hourKey)
  if (hourCount === 1) await redis.expire(hourKey, 3600)

  const dayCount = await redis.incr(dayKey)
  if (dayCount === 1) await redis.expire(dayKey, 86400)

  return hourCount <= cfg.max_per_hour && dayCount <= cfg.max_per_day
}


async function inQuietHours(userId: string): Promise<boolean> {
  const cfg  = (await configService.get('push_notifications')).rate_limits
  const user = await usersRepo.get(userId)
  const tz   = user.timezone ?? 'UTC'

  const localHour = new Date().toLocaleString('en-US', {
    timeZone: tz, hour: 'numeric', hour12: false,
  })
  const hour  = Number(localHour)
  const start = cfg.quiet_hours_start   // e.g. 23
  const end   = cfg.quiet_hours_end     // e.g. 7

  if (start > end) {   // Nocna przerwa (23→7)
    return hour >= start || hour < end
  }
  return hour >= start && hour < end    // Dzienna przerwa (rzadko)
}


async function cleanupStaleTokens(
  userId: string,
  tokens: string[],
  response: admin.messaging.BatchResponse,
): Promise<void> {
  const stale = tokens.filter((_, i) => {
    const r = response.responses[i]
    return !r.success && r.error?.code === 'messaging/registration-token-not-registered'
  })
  if (stale.length > 0) {
    // Usuń stale tokeny z deviceTokens JSONB
    await usersRepo.removeTokens(userId, stale)
  }
}
```

### 8c.5 BullMQ Workers dla zaplanowanych powiadomień

```typescript
// workers/notification-worker.ts
import { Worker, Job } from 'bullmq'
import { redis } from '../db/redis'
import { pushService } from '../services/push-service'
import { questAttempts } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'
import { db } from '@20hero/db'
import { logger } from '../lib/logger'

new Worker('notification', async (job: Job) => {

  if (job.name === 'send-quest-warning') {
    // Zaplanowane w momencie przyjęcia questa — delay = expiresAt - 5min
    const { questAttemptId, userId, questTitle } = job.data
    const attempt = await db.query.questAttempts.findFirst({
      where: eq(questAttempts.id, questAttemptId),
    })
    if (!attempt || attempt.status !== 'in_progress') return  // Quest już zakończony / porzucony

    await pushService.notify(userId, {
      notificationType: 'quest_expiring',
      title: 'Quest wygasa!',
      body: `Masz 5 minut na ukończenie: ${questTitle}`,
      data: { questId: attempt.questId },
    })
  }

  else if (job.name === 'daily-reminders') {
    // Cron: uruchamiany co 15 minut, sprawdza które strefy mają teraz 09:00
    const now = new Date()
    const currentUtcHour   = now.getUTCHours()
    const currentUtcMinute = now.getUTCMinutes()

    // Znajdź użytkowników, dla których jest teraz 09:00-09:14 lokalnie
    const users = await usersRepo.getUsersAtLocalTime({
      targetHour: 9,
      utcOffsetMinutes: currentUtcHour * 60 + currentUtcMinute,
      prefsFilter: 'daily_quest_reminder',
    })

    for (const user of users) {
      // Sprawdź czy nie grał dzisiaj (nie potrzebuje przypomnienia)
      if (user.lastQuestCompletedAt) {
        const today = new Date().toISOString().slice(0, 10)
        if (user.lastQuestCompletedAt.toISOString().slice(0, 10) === today) continue
      }

      await pushService.notify(user.id, {
        notificationType: 'daily_quest_reminder',
        title: 'Czas na quest!',
        body: `Co dzisiaj zdobędziesz, ${user.characterName}?`,
        data: { screen: 'quests_nearby' },
      })
    }
  }

  else if (job.name === 'streak-warnings') {
    // Cron: co godzinę. Znajdź graczy których streak wygasa za ~24h
    const expiringStreaks = await questAttemptsRepo.getExpiringStreaks({ withinHours: 24 })

    for (const item of expiringStreaks) {
      const hoursLeft = Math.floor(item.hoursUntilExpiry)
      await pushService.notify(item.userId, {
        notificationType: 'streak_warning',
        title: 'Uwaga! Streak zagrożony',
        body: `Masz ${hoursLeft}h na zaliczenie questa, żeby utrzymać passę ${item.streakDays} dni`,
        data: { screen: 'quests_nearby' },
      })
    }
  }

}, { connection: redis })
```

**Harmonogram (BullMQ repeat jobs — zarejestrowane w registerScheduledJobs()):**

```typescript
// Poniższe joby są zdefiniowane w queues/index.ts → registerScheduledJobs().
// Kanoniczne nazwy jobów są zdefiniowane tam.
// NIE dodawaj tu drugiego zestawu scheduled jobs — jeden blok wystarczy.

await votingQueue.add('close-expired-votes', {}, {
  repeat: { pattern: '*/15 * * * *' },
  jobId: 'close-expired-votes',
})
```

**Planowanie quest_expiring w momencie przyjęcia questa:**

```typescript
// services/quest-service.ts → acceptQuest()

export async function acceptQuest(userId: string, questId: string): Promise<QuestAttempt> {
  const attempt = await questAttemptsRepo.create({ userId, questId })

  // Zaplanuj ostrzeżenie 5 minut przed wygaśnięciem
  const warningDelayMs = attempt.expiresAt.getTime() - Date.now() - 5 * 60 * 1000
  await notificationQueue.add(
    'send-quest-warning',
    { questAttemptId: attempt.id, userId, questTitle: attempt.quest.title },
    { delay: Math.max(0, warningDelayMs) },
  )

  return attempt
}
```

### 8c.6 API Endpoints

```
POST /api/auth/device-token
  Body: { token: string, platform: "android"|"ios", appVersion: string }
  → Dodaje token do users.device_tokens (deduplication po token string)
  → Aktualizuje lastUsedAt jeśli token już istnieje

DELETE /api/auth/device-token
  Body: { token: string }
  → Usuwa token (np. przy wylogowaniu)

GET /api/users/me/notification-prefs
  → Zwraca merged prefs (default + user overrides)
  Response: { quest_expiring: true, daily_quest_reminder: false, ... }

PATCH /api/users/me/notification-prefs
  Body: { daily_quest_reminder: true, reaction_on_proof: false }
  → Ustawia TYLKO podane klucze w users.notification_prefs JSONB
  → Walidacja: klucz musi istnieć w default_prefs (whitelist)
```

### 8c.7 Gdzie wywoływać `pushService.notify()`

Kompletna lista miejsc w kodzie:

```typescript
// quest-service.ts → completeQuest() (AI auto-verified)
await pushService.notify(userId, {
  notificationType: 'quest_verified',
  title: 'Quest zaliczony!',
  body: `Zdobywasz ${xp} XP i ${tokens} HERO za ${quest.title}`,
  data: { screen: 'profile' },
})

// quest-service.ts → rejectQuest() (AI rejected)
await pushService.notify(userId, {
  notificationType: 'quest_rejected',
  title: 'Proof odrzucony',
  body: `${quest.title} — ${result.userFeedback}`,
  data: { questId, screen: 'quest_retry' },
})

// voting-service.ts → resolveVote() → accepted
await pushService.notify(proof.userId, {
  notificationType: 'vote_result_approved',
  title: 'Spolecznosc zatwierdzila!',
  body: `Zdobywasz ${xp} XP za ${quest.title}`,
  data: { screen: 'profile' },
})

// voting-service.ts → resolveVote() → rejected
await pushService.notify(proof.userId, {
  notificationType: 'vote_result_rejected',
  title: 'Proof odrzucony przez glosowanie',
  body: `Sprobuj jeszcze raz: ${quest.title}`,
  data: { questId, screen: 'quest_retry' },
})

// xp-service.ts → award() → when level crosses threshold
await pushService.notify(userId, {
  notificationType: 'level_up',
  title: `Level ${newLevel}!`,
  body: `Osiagnales level ${newLevel}! ${xpToNext} XP do nastepnego.`,
  data: { screen: 'character' },
})

// workers/avatar-worker.ts → generateAvatarTier() → after S3 upload
await pushService.notify(character.userId, {
  notificationType: 'avatar_evolved',
  title: 'Nowy avatar gotowy!',
  body: `Twoj ${classDisplay} ewoluowal do Tier ${newTier}!`,
  data: { screen: 'character_avatar' },
})

// social-service.ts → follow()
await pushService.notify(followedId, {
  notificationType: 'new_follower',
  title: 'Nowy obserwujacy',
  body: `${followerUsername} zaczal cie obserwowac`,
  data: { userId: followerId, screen: 'user_profile' },
})

// comments-service.ts → createComment()
await pushService.notify(proof.userId, {
  notificationType: 'comment_on_proof',
  title: 'Nowy komentarz',
  body: `${commenterUsername}: "${commentText.slice(0, 60)}"`,
  data: { proofId, screen: 'proof_detail' },
})

// reactions-service.ts → addReaction()  [opt-in, domyslnie false]
await pushService.notify(proof.userId, {
  notificationType: 'reaction_on_proof',
  title: 'Reakcja na twoj proof',
  body: `${reactorUsername} polubil twoje zaliczenie questa`,
  data: { proofId, screen: 'proof_detail' },
})

// leaderboard-service.ts → update() → gdy ktos wyprzedza w top 10
await pushService.notify(overtakenUserId, {
  notificationType: 'leaderboard_overtaken',
  title: 'Wyprzedzono cie!',
  body: `${overtakerUsername} zajal twoje miejsce #${position} w rankingu`,
  data: { screen: 'leaderboard' },
})
```

### 8c.8 Bezpieczeństwo i edge cases

| Problem | Rozwiązanie |
|---------|-------------|
| Stale token (app odinstalowana) | `cleanupStaleTokens()` usuwa przy `messaging/registration-token-not-registered` |
| Powielone powiadomienia (retry BullMQ) | Worker idempotent — sprawdza status przed wysłaniem |
| Flood reakcji (100 reakcji = 100 pushów) | `reaction_on_proof` domyślnie `false`; rate limit 5/h |
| Quiet hours — quest wygasa w nocy | `CRITICAL_TYPES = new Set(['quest_expiring'])` — ignoruje quiet hours |
| Brak strefy czasowej gracza | Domyślnie UTC; app pyta o zgodę geolokalizacji przy onboardingu |
| Deduplication tokenów | `POST /api/auth/device-token` sprawdza token w istniejącej tablicy JSONB |
| Token rotation (FCM wymaga odświeżenia) | App wysyła nowy token przy starcie; `lastUsedAt` aktualizowany; stare tokeny (>90 dni nieaktywne) usuwane przez cron |

---


