# FS Tracker v2 — İstemci Sözleşmesi

Mobil (iOS / Android) ve masaüstü istemcilerin uyması gereken protokol.
Bu doküman yayındaki sunucudan servis edilir, yani her zaman çalışan sürümle aynıdır.

- **Taban adres:** `https://api.fstracker.app`
- **Bu doküman:** `GET /docs` (markdown) · `GET /docs/openapi.json` (OpenAPI 3.1)
- **Sağlık:** `GET /health`

Tarih: sunucu her şeyi UTC üretir, ISO 8601 döner. Mesafeler deniz mili (NM),
irtifalar fit (ft), hızlar knot (kt), dikey hız fit/dakika (ft/dk), ağırlık pound (lb).

---

## 1. Kimlik doğrulama

Mobil istemciler **JWT** kullanır. Masaüstü uygulaması ayrıca uzun ömürlü bir
**cihaz anahtarı** alır (telemetri göndermek için); mobil tarafın buna ihtiyacı yoktur.

### Kayıt

```http
POST /v1/auth/register
Content-Type: application/json

{ "email": "pilot@example.com", "password": "enaz8karakter", "displayName": "Mustafa" }
```

```json
{ "token": "eyJ...", "user": { "id": "uuid", "email": "...", "displayName": "...", "shareLive": true } }
```

### Giriş

```http
POST /v1/auth/login
{ "email": "...", "password": "..." }
```

Her üç giriş yolu da aynı yanıtı verir:

```json
{ "token": "<erişim, 1 gün>", "refreshToken": "<yenileme, 180 gün>", "user": { ... } }
```

### Apple ve Google ile giriş

```http
POST /v1/auth/apple    { "identityToken": "<Apple identity token>", "fullName": "Mustafa" }
POST /v1/auth/google   { "idToken": "<Google ID token>" }
```

Sunucu belirteci sağlayıcının JWKS anahtarlarıyla **kendisi doğrular** (imza,
issuer, audience, süre). İstemci sağlayıcı belirtecini saklamamalı.

Aynı doğrulanmış e-posta farklı sağlayıcıdan gelirse yeni hesap açılmaz, mevcut
hesaba bağlanır. Apple e-postayı gizlerse (`@privaterelay.appleid.com`)
birleştirme yapılamaz; `apple_id` tek kimlik olur.

### Oturum yenileme

```http
POST /v1/auth/refresh  { "refreshToken": "..." }
POST /v1/auth/logout   (JWT)
```

Yenileme belirteci **dönüşümlüdür**: her kullanımda yenisi verilir, eskisi
iptal edilir. İptal edilmiş bir belirteç tekrar kullanılırsa çalınmış sayılır
ve o kullanıcının **tüm oturumları kapatılır** — istemci bu durumda 401 alır ve
kullanıcıyı giriş ekranına döndürmeli.

### Masaüstü eşleştirme

Apple/Google ile kaydolan kullanıcının parolası yoktur, Windows uygulamasına
e-posta+parola ile giremez. Mobil kısa ömürlü bir kod üretir:

```http
POST /v1/auth/pair/start   (JWT)  → { "code": "S8NT-4PG", "expiresAt": "..." }
POST /v1/auth/pair/claim          → { "id": "...", "token": "<cihaz anahtarı>",
                                      "user": { "displayName": "...", "email": "..." } }
```

Kod 5 dakika yaşar, **tek kullanımlıktır**, kullanıcı başına en fazla 3 açık kod
olabilir. `claim` yetki istemez — kodun kendisi kanıttır. Mobilde kodu büyük
puntoyla göster ve kalan süreyi say.

Yanıtta `user` da dönüyor: masaüstünün elinde yalnızca cihaz anahtarı kalır ve
onunla `/v1/auth/me` çağrılamaz, yani kimin bağlandığını başka türlü bilemez.

Yetki gerektiren her istekte:

```http
Authorization: Bearer <token>
```

### Profil

| Uç | Açıklama |
|---|---|
| `GET /v1/auth/me` | Kullanıcı bilgisi |
| `PATCH /v1/auth/me` | `{ "shareLive": false }` — canlı haritada görünmeyi kapatır |

`shareLive` kapalıyken o kullanıcının uçuşu canlı haritada **hiç yayınlanmaz**;
uçuş kaydı ve geçmişi normal işler. Gizlilik ayarı olarak sunulmalı.

---

## 2. Uçuş geçmişi

### Liste

```http
GET /v1/flights?limit=20&before=2026-10-22T10:00:00Z
```

Sayfalama imleçlidir (offset değil). Yanıttaki `nextCursor` bir sonraki isteğin
`before` değeridir; `null` ise liste bitmiştir.

```json
{
  "items": [{
    "id": "uuid",
    "state": "completed",
    "aircraftTitle": "A220-300",
    "aircraftModel": "A220-300",
    "aircraftType": "223",
    "livery": "Air France A220-300",
    "tailNumber": null,
    "flightNumber": "TK1979",
    "departureIcao": "LTFM",
    "departureName": "İstanbul Airport",
    "arrivalIcao": "LTBJ",
    "arrivalName": "Adnan Menderes International Airport",
    "startedAt": "2026-10-22T10:37:14.334Z",
    "takeoffAt":  "2026-10-22T10:38:46.512Z",
    "landingAt":  "2026-10-22T10:41:32.009Z",
    "endedAt":    "2026-10-22T11:10:36.448Z",
    "distanceNm": 10.09,
    "maxAltitudeFt": 3222,
    "landingVsFpm": -2913,
    "trackPointCount": 428,
    "airSeconds": 165
  }],
  "nextCursor": "2026-10-22T10:37:14.334Z"
}
```

`state`: `active` | `completed` | `aborted`.
`completed` yalnızca uçak havalandıysa verilir; hiç kalkmadan biten kayıt `aborted` olur.

**İki ayrı süre var ve karıştırılmamalı:**

| Süre | Nasıl hesaplanır | Ne zaman kullanılır |
|---|---|---|
| **Blok** | `endedAt − startedAt` | Motor çalıştırmadan kapatmaya. Parkta beklenen zamanı da sayar |
| **Havada** | `airSeconds` (= `landingAt − takeoffAt`) | Asıl uçuş süresi. Ekranlarda bunu göster |

3 dakikalık bir uçuştan sonra 30 dakika parkta beklenirse blok 33 dakika,
`airSeconds` 180 olur. Kullanıcıya "uçuş süresi" olarak **havada geçeni** sunun.

**Kalkış/varış havalimanı** iz noktalarının ilkine ve sonuncusuna en yakın
havalimanından türetilir (5 NM yarıçap, helipad'e karşı gerçek havalimanı
tercih edilir). Açık denizde ya da havalimanından uzakta başlayan uçuşta
`null` kalır.

**Dikkat:** `trackPointCount` uçuş kapanırken hesaplanır; **devam eden uçuşta 0** döner.
Aktif uçuşun nokta sayısını göstermek gerekiyorsa `/v1/flights/:id/track` uzunluğunu kullan.

### Tek uçuş

```http
GET /v1/flights/{id}
```

```json
{ "flight": { ...tüm sütunlar... }, "events": [ { "t": "...", "type": "takeoff", "data": null } ] }
```

Olay tipleri: `takeoff`, `landing`, `touchdown` (ölçülmüş temas değerleriyle).

### İz

```http
GET /v1/flights/{id}/track?maxPoints=2000
```

```json
{ "flightId": "uuid", "points": 428,
  "track": [ { "t": "...", "lat": 41.264, "lon": 28.725, "altFt": 330,
               "gsKt": 0, "iasKt": 0, "vsFpm": 0, "hdgDeg": 353, "onGround": true } ] }
```

Noktalar eşit aralıklı örneklenerek `maxPoints`'e indirilir (varsayılan 2000, azami 20000).
Haritada çizgi çizmek için 1000–2000 fazlasıyla yeterlidir; daha fazlası yalnızca
cihazı yorar.

### İstatistik

```http
GET /v1/stats
```

```json
{
  "totals": { "totalFlights": 2, "totalSeconds": "2043", "totalAirSeconds": "165",
              "totalDistanceNm": 11.45, "totalLandings": 1,
              "bestLandingFpm": -2913, "avgLandingFpm": -2913 },
  "aircraft": [ { "aircraftType": "223", "flights": 1, "seconds": "2002", "distanceNm": 10.09 } ],
  "airports": []
}
```

`totalSeconds`, `totalAirSeconds` ve `seconds` **string** döner (Postgres `bigint`).
İstemcide sayıya çevir.

`bestLandingFpm` sıfıra en yakın (en yumuşak) inişin işaretli değeridir.
`aircraft[].seconds` havada geçen süredir, blok değil.

İstatistikler her uçuş kapanışında uçuş tablosundan **baştan hesaplanır**;
artımlı sayaç tutulmadığı için elle düzeltme ya da yeniden kapatma sonrasında
kaymazlar.

---

## 2.5 Abonelik ve demo

```http
GET /v1/subscription   (JWT)
→ { "active": true, "status": "active", "entitlement": "pro",
    "productId": "...", "store": "APP_STORE", "currentPeriodEnd": "..." }
```

Yetkilendirme **sunucuda** zorlanır; istemcideki RevenueCat SDK'sı yalnızca
paywall'ı beklemeden göstermek içindir. Çeliştiklerinde sunucu kazanır.

**Abonelik yokken:**

| Uç | Davranış |
|---|---|
| `GET /v1/flights` | Yalnızca demo uçuş döner, yanıtta `"demo": true` |
| `GET /v1/flights/{id}` | Demo dışındaki her kimlik **404** |
| `GET /v1/flights/{id}/track` | Aynı |
| `GET /v1/stats` | Demo uçuştan türetilmiş istatistik, `"demo": true` |
| `GET /v1/live/flights` | **403** |
| `wss://.../live` | Bağlantı `4003 subscription_required` ile kapanır |

Demo uçuş gerçek bir uçuşla birebir aynı şekle sahiptir (TK2334, A320neo,
LTFM→LTBJ, 45 dk, 193 NM, 778 iz noktası), bu yüzden istemcide ikinci bir veri
yolu gerekmez — yalnızca `demo` bayrağına bakıp "örnek veri" rozeti göster.

İptal edilmiş abonelik **dönem sonuna kadar** geçerlidir: yetki kararı
`currentPeriodEnd` tarihine bakar, duruma değil.

## 3. Canlı harita (WebSocket)

```
wss://api.fstracker.app/live
```

**Oturum ve abonelik gerekir.** Belirteç sorgu dizesiyle verilir (WebSocket el
sıkışmasında özel başlık gönderilemiyor):

```
wss://api.fstracker.app/live?token=<JWT>
```

Belirteç geçersizse `4001`, abonelik yoksa `4003` ile kapanır; kapanmadan önce
`{"t":"error","code":"subscription_required"}` gönderilir.

### İstemci → sunucu

```jsonc
{"t":"viewport","n":42.0,"s":40.0,"e":30.0,"w":27.0,"z":9}  // haritayı her kaydırdığında
{"t":"follow","flightId":"uuid"}                             // kutu dışına çıksa da takip et
{"t":"follow","flightId":null}                               // takibi bırak
{"t":"ping"}
```

`viewport` gönderdiğinde sunucu fark tablosunu sıfırlar ve görünür uçakların
tamamını `add` olarak yeniden yollar. Bu yüzden **her küçük kaydırmada gönderme** —
harita hareketini 300–500 ms geciktirerek (debounce) yolla.

### Sunucu → istemci

```jsonc
{"t":"stats","activeFlights":37}            // bağlanır bağlanmaz
{"t":"fl","add":[[...]],"upd":[[...]],"del":["uuid"]}
{"t":"cluster","cells":[[41.5,28.5,12]]}    // zoom < 6
{"t":"pong"}
```

Uçak kaydı **sabit sıralı dizidir** (alan adı yok, bant genişliği için):

```
[0] flightId   [1] lat   [2] lon   [3] track°   [4] gsKt
[5] altFt      [6] vsFpm [7] callsign           [8] acType
```

`add` yeni giren, `upd` değişen, `del` kutudan çıkan uçakları taşır. Üçü de
isteğe bağlıdır — yalnızca dolu olanlar gönderilir.

Kümeleme hücresi: `[lat, lon, adet]`. Zoom 6'nın altında tek tek uçak **gelmez**,
yalnızca sayılar gelir; haritada balon/rozet olarak göster.

### Önemli: ölü hesap (dead reckoning)

Sunucu **2 saniyede bir** yayın yapar. Uçak ikonlarını doğrudan bu konumlara
koyarsan harita zıplar. Her kare arasında konumu ileri sar:

```
geçenSüre = şimdi - paketinAlındığıAn           // saniye
mesafeNm  = gsKt / 3600 * geçenSüre
lat += mesafeNm / 60 * cos(track)
lon += mesafeNm / 60 * sin(track) / cos(lat)
```

Yeni paket geldiğinde gerçek konuma yumuşak geçiş yap (~300 ms animasyon),
yoksa düzeltmeler sıçrama olarak görünür.

### Sınırlar

- Bir viewport'ta en fazla **300 uçak** döner. Daha kalabalık bölgede uzaklaş.
- Yavaş istemcide tampon 1 MB'ı aşarsa sunucu o yayını atlar; mesaj kaybı
  tolere edilmelidir (bir sonraki tick zaten tam durumu taşır).
- Canlı kayıt **180 saniye** güncellenmezse ölü sayılıp düşürülür.

### REST alternatifi

```http
GET /v1/live/flights?n=42&s=40&e=30&w=27
GET /v1/live/summary
```

İlk ekran yüklemesi ve WebSocket kurulamadığı durumlar için. **Sürekli yoklama
yapma** — canlı takip için WebSocket kullan.

---

## 4. Telemetri girişi (yalnızca masaüstü)

Mobil istemcilerin bu uca ihtiyacı yoktur; bütünlük için belgelenmiştir.

```
wss://api.fstracker.app/ingest?token=<cihazAnahtarı>
```

Cihaz anahtarı `POST /v1/auth/device-tokens` (JWT ile) üretir; **ham değer
yalnızca o yanıtta bir kez döner**, sunucuda özeti saklanır.

Mesajlar: `start`, `pos`, `batch`, `event`, `end`, `ping` →
`ready`, `started`, `ack`, `ended`, `error`, `pong`.

Konum dizisi 20 elemanlıdır ve sırası sabittir (`src/protocol.ts`).
Hız sınırı jeton kovasıdır: 60 yığın payı, saniyede 5 dolum.

---

## 5. Hatalar

NestJS standart gövdesi:

```json
{ "statusCode": 401, "message": "E-posta veya parola hatalı", "error": "Unauthorized" }
```

`message` bazen dizi olur (doğrulama hataları). İstemcide ikisini de karşıla.

| Kod | Anlamı |
|---|---|
| 400 | Eksik/geçersiz alan |
| 401 | Token yok, geçersiz ya da süresi dolmuş |
| 404 | Kayıt yok ya da sana ait değil |
| 409 | E-posta zaten kayıtlı |

---

## 6. Geliştirirken test verisi

Canlı haritayı boş ekranla geliştirmek zor. Sunucuda uçuş simülatörü var:

```bash
ssh fstracker
cd /root/fstracker-v2
# N test uçağını havaya kaldırır (token üretimi: tools/loadtest-users.sql)
docker exec fstracker-v2-api node /app/simulate.js \
  --api http://localhost:3000 --tokens "<virgülle ayrılmış>" --seconds 600
```

Uçaklar dünyaya dağılmış biçimde kalkar, böylece viewport ve kümeleme
davranışını gerçekçi şekilde denersin.

---

## 7. Sözleşmedeki boşluklar

Mobil tarafı kodlarken bunları bilerek planla:

- REST uçlarında hız sınırı yok
- `GOOGLE_CLIENT_IDS` sunucuda tanımlanana kadar `POST /v1/auth/google` **503** döner
