# Chunk 3 — Talep ve yazışma omurgası

*Index: `musteri-portali-plan.md` · Spec: §4, §7 · Tasarım: `34`, `20`*

Yazışma önce gelir çünkü teklif (chunk 4) ve fatura ikisi birden ona yaslanır.

---

## 1. Talep — `training_requests` geçişi

⛔ Index §5'in additive geçişi. Sıra bağlayıcı.

### 1.1 Yeni yardımcı tablolar
- `training_request_definitions` — `training_request_id` · `training_definition_id` (çoğul tanım)
- `training_request_invitees` — `training_request_id` · `customer_id` (yalnız kapalıda)
- `training_request_scopes` — `training_request_id` · `scope_type` (`all` \| `group` \| `user`) · `group_id` \| `user_id` (K-30)

### 1.2 `training_requests` kolonları
Eklenen: `city_option_value_id` · `visibility` (`open` \| `closed`) · `origin` (`customer` \| `sales`) · `reference_code_id` · `source_query` (nullable — talebi doğuran sorgulama, tasarım `34`).
⚠ Mevcut `training_definition_id` **bırakılır** ve yeni kod okumaz; `location_note` serbest metin olarak kalır, `city_option_value_id` onun yerine geçer.

### 1.3 RLS değişikliği — kimlik alanı farkındalığı

⛔ **Bu adım ayrı gözden geçirilir.** Önce **mekanizma** (index P-9): `app.identity_domain` GUC'u `TenantContext` tarafından `SET LOCAL` ile kurulur; `app.is_office()` onu okur (`app.audit_read_all()` deseni).

```sql
CREATE OR REPLACE FUNCTION app.is_office() RETURNS boolean
AS $$ DECLARE v text := current_setting('app.identity_domain', true);
     BEGIN RETURN v = 'office'; END $$ LANGUAGE plpgsql STABLE;

-- training_requests
USING ((tenant_id IS NULL AND app.is_office()) OR tenant_id = ANY (app.tenant_ids()))
```

⛔ **Naif hâli kendi testini geçemez** (heyet 174 F-P1): `tenant_id IS NULL OR tenant_id = ANY(...)` yazılırsa, müşteri oturumu da `app.tenant_ids()` kurduğu için **ilk koşul müşteriye de açık kalır** ve B firmasının kullanıcısı bütün satış taleplerini okur.

⚠ Mevcut satırlar `origin='customer'` doldurulur; CHECK: `origin='customer'` ⇒ `tenant_id IS NOT NULL`.

⛔ **Yardımcı tabloların üçü de policy alır** ve aynı kuralı taşır. Denormalizasyon: her biri `tenant_id` (nullable) taşır ve ebeveyninden kopyalar — RLS içinde `EXISTS` ile ebeveyne bakmak yerine, çünkü policy her satırda koşar.

⛔ **`training_request_invitees` özellikle kritiktir:** davet edilen **firmaların kimliklerini** taşır. Bir müşteriye sızması doğrudan çapraz-tenant ticari sızıntıdır — bu tablo satış tarafında yaşar (`tenant_id IS NULL`) ve yalnız ofiste görünür.

⛔ Negatif testler **zorunlu, ama doğru katmanda**:

| # | Senaryo | Katman | Grup |
|---|---|---|---|
| (a) | B tenant'ı A'nın **müşteri** talebini göremez | RLS | `omurga-rls` |
| (c) | Müşteri kullanıcısı **satış talebini ve davet listesini** hiç göremez — okuma **ve** `tenant_id IS NULL` satır **ekleme** | RLS | `omurga-rls` |
| (b) | Ofis kullanıcısı **kapsamı dışındaki** satış talebini göremez | **uygulama** | `satis` / `planlama` |

⚠ **(b) RLS'in sağladığı bir şey değildir** (heyet 175 R2P-2): P-9 policy'si ofis oturumuna **tüm** `tenant_id IS NULL` satırları açar; K-30 kapsam süzgeci **bilinçli olarak query katmanındadır** (§2 `TrainingRequestQueue`, M-7). (b)'yi `omurga-rls`'de aramak ya her zaman düşer ya da policy'ye kapsam eklenerek "geçirilir" — ikincisi mimariyi bozar.

⚠ `WITH CHECK` `USING` ile **aynı ifadeyi** taşır: müşteri oturumu `tenant_id IS NULL` satır **ekleyemez** de. (c) bunu INSERT senaryosuyla da ölçer.

### 1.4 Referans kodu
`TL` öneki `ReferencePrefix`'e eklenir. ⛔ İki harf ve **rastgele son ek** (spec §10.2, AD-19) — tasarımdaki `TLP-2026-0094` üç harf **ve** ardışık; ikisi de geçersiz.

---

## 2. Talep — Domain, Action, Query

`app/Domain/Trainings/RequestVisibility.php`
```
contradicts(string $visibility, bool $hasInvitees, bool $hasScope): bool
```
⛔ Kapalı + davetsiz, ya da açık + davetli **çelişkidir** ve kaydedilmeden reddedilir (spec §4.3).

| Action | Not |
|---|---|
| `OpenTrainingRequest` | satışçı ya da müşteri; `origin` kaynağa göre. Kod üretir. ⚠ Çelişki kontrolü |
| `PlanTrainingRequest` | ⛔ talebi plana çevirir: eğitimler, il, tarih aralığı, **görünürlük ve kapsam** taşınır (spec §4.7, M-10) |
| `DeclineTrainingRequest` | ⛔ gerekçe zorunlu, talep sahibine aynen görünür |
| `ReopenTrainingRequest` | reddedilen talep yeni bilgiyle yeniden açılır |

`Office\TrainingRequestQueue` — planlamacı hepsini, satışçı **üç kümenin birleşimini** görür: atandığı firmalar + kendi açtıkları + **kapsamına dahil olduğu açık talepler** (spec §4.4, M-7).
⚠ Her satır **bekleme süresi** ve **son hamle** taşır (spec §4.5) — son hamle §3'ün thread'inden gelir.

⛔ **Kapsam okunur** (M-43): `Office\VisibleTrainings` — satışçının gördüğü eğitim listeleri (teklif ekranı, takvim, sorgulama) kapsama göre süzülür. ⚠ Bu, chunk 4'ün teklif ekranının **girdisidir**; burada yazılır çünkü kapsamı üreten yer burası.

---

## 3. Yazışma

### 3.1 Migration'lar
- `correspondence_threads` — `id` · `subject_type` + `subject_id` (eğitim \| teklif \| fatura) · `reference_code_id` · `channel` · timestamps
- `correspondence_messages` — `thread_id` · `direction` (`outbound` \| `inbound`) · **`party`** (`customer` \| `supplier` \| `accounting` \| `internal`) · `from` · `to` · `subject` · `body` · `provider_message_id` · `received_at` · timestamps
- `correspondence_attachments` — `message_id` · `storage_disk` · `storage_path` · `content_hash` · `byte_size` · `mime_type`

⛔ **Tenant policy YOK** — thread'ler ofis tarafında yaşar (spec §10.1, index P-4). Migration yorumu bunu **gerekçesiyle** yazar; policy'nin yokluğu bilinçli olmalı.

⛔ **`party` kolonu görünürlüğün taşıyıcısıdır** (spec §10.1, M-42): müşteri yalnız `party='customer'` mesajlarını görür. 3E ↔ muhasebe (`accounting`) ve iç notlar (`internal`) ona kapalıdır. ⚠ Görünürlük **thread düzeyinde değil mesaj düzeyinde** çözülür — aynı fatura kaydına iki taraf da yazıyor.

### 3.2 Inbound
`app/Integrations/Postmark/InboundWebhook.php` — ⛔ SDK ve HTTP yalnız burada (AD-25).
Akış: imza doğrula → `To` local-part'ından referans kodu → kaydı bul → `party` çöz → mesajı yaz → ekleri hash'le S3'e → sahibine bildirim.

⛔ **Eşleşmeyen mail triyaj kuyruğuna** (M-22): `correspondence_triage` — kaybolmaz. Auto-reply/OOO filtrelenir, spam eşiği üstü karantina.
⛔ **Hiçbir iş aksiyonu tetiklenmez** (M-21, AD-20 §1): webhook yalnız mesaj yazar ve bildirim üretir; teklif/fatura durumuna **dokunmaz**.

⚠ `Http::preventStrayRequests()` testlerde açık; webhook testleri sahte payload ile koşar.

### 3.3 Outbound
`SendRecordMail` — Reply-To referans kodunu taşır. Kuyruğa gider. ⚠ Gönderilen mesaj da `correspondence_messages`'a **outbound** olarak yazılır; thread tek gerçektir.

---

## 4. Frontend

| Dosya | Tasarım |
|---|---|
| `pages/Office/Requests/Create.vue` | `34` Ekran A — satışçı |
| `pages/Office/Requests/Index.vue` | `34` Ekran B — kuyruk |
| `organisms/CorrespondenceThread.vue` | thread görüntüleyici; ⛔ `party` süzgeci **bileşende değil query'de** |
| `molecules/LastMoveRow.vue` | son hamle satırı (K-28) — kuyrukta ve teklifte aynı bileşen |

⛔ `34`'ün kapsam kontrolü (`Kimler görsün`) `24`'e de eklendi (tasarım revizesi); `Plan.vue`'nun satırına kapsam alanı girer ve `PlanTraining` onu yazar (M-10).

---

## 5. Test planı

| Grup | Senaryo |
|---|---|
| `omurga` (unit) | `RequestVisibility` çelişki matrisi |
| `satis` | talep açma · çelişki reddi · kod üretimi (iki harf, rastgele) · kuyrukta üç kümenin birleşimi (M-7) · gerekçesiz ret reddedilir |
| `planlama` | plana çevirme: eğitimler, il, tarih, görünürlük **ve kapsam** taşınır (M-10) · kapsam eğitim listelerinde **süzer** (M-43) |
| `entegrasyon` | inbound: imza doğrulama · kod eşleştirme · eşleşmeyen triyaja · auto-reply filtresi · ⛔ **teklif durumuna dokunulmadığı** (M-21) |
| `musteri` | müşteri `party='accounting'` ve `internal` mesajları **görmez** (M-42) |
| `omurga-rls` | `training_requests` + üç yardımcı tablo: üç negatif senaryo (§1.3). ⛔ **`app.identity_domain` hiç kurulmamışken policy'nin KAPALI kaldığını gösteren test** — `current_setting(..., true)` NULL döner, `app.is_office()` **false** verir, satır **görünmez**. ⚠ v1.0 bu cümleyi **ters** yazmıştı ("açık kaldığını"); birebir uygulayan biri fail-open'ı doğrulayan bir test yazar ve güvenlik hatasını yeşile boyardı (heyet 175 R2P-1). Fail-closed bu mekanizmanın **bütün varlık sebebidir** |
| `omurga` | audit: talep açma/planlama/ret `audit_writes` üretir ve **aynı transaction**'da yazılır (AD-06) · ⛔ **thread okuması `audit_access`'e düşer** (AD-21, spec §7.3 — mail gövdesi ve ekleri kişisel veri içerir) |
| `satis` / `planlama` | ⛔ **kapsam süzgeci uygulama katmanında**: ofis kullanıcısı kapsamı dışındaki satış talebini kuyrukta görmez (R2P-2'nin (b) senaryosu) |

---

## 6. Kapsama

| Spec | Karşılığı |
|---|---|
| M-4, M-5, M-6 | `OpenTrainingRequest` + `RequestVisibility` + `TL` kodu |
| M-7 | `TrainingRequestQueue` üç küme |
| M-8 | `LastMoveRow` + bekleme süresi + hatırlatma görevi |
| M-9 | `DeclineTrainingRequest` |
| M-10 | `PlanTrainingRequest` + `Plan.vue` kapsam alanı |
| M-21, M-22 | inbound webhook |
| M-42 | `party` kolonu + query süzgeci |
| M-43 | `VisibleTrainings` |
