> ## Documentation Index
> Fetch the complete documentation index at: https://docs.diyanet.pro/llms.txt
> Use this file to discover all available pages before exploring further.

# Notification Servisi (SSE)

> Server-Sent Events tabanlı admin bildirimleri — in-memory broadcaster, rol-hedefli yayın ve HMAC stream-token.

Bildirim servisi `src/DiyanetCleanArchitecture.Infrastructure.Services.Notification` projesindedir. Admin panelindeki kullanıcılara **gerçek-zamanlı** bildirim push etmek için **Server-Sent Events (SSE)** kullanır. Transport-agnostic bir broadcaster arayüzü etrafında kuruludur.

## Broadcaster arayüzü

```csharp theme={null}
public interface IAdminNotificationBroadcaster
{
    Task PublishAsync(NotificationEnvelope notification, CancellationToken cancellationToken = default);
    SubscriptionHandle Subscribe(Guid userId, IReadOnlyList<int> roleIds);
    void Unsubscribe(SubscriptionHandle handle);
}
```

Default implementasyon `InMemoryAdminNotificationBroadcaster`'dır (singleton, tek API instance için). Her SSE bağlantısı bir `Channel<NotificationEnvelope>` tutar.

## Rol-hedefli yayın

`PublishAsync`, `NotificationEnvelope.TargetRoleId` alanına göre filtre uygular: `null` ise tüm personele, dolu ise yalnızca o role sahip abonelere yazar. Yazım **bekletmez** — `DropOldest` ile en eski mesaj düşürülür:

```csharp theme={null}
public Task PublishAsync(NotificationEnvelope notification, CancellationToken cancellationToken = default)
{
    foreach (var sub in _subs.Values)
    {
        if (notification.TargetRoleId is int target && !sub.RoleIds.Contains(target))
            continue;

        sub.Writer.TryWrite(notification);
    }
    return Task.CompletedTask;
}
```

Abonelik kanalı **kapasite 50** ve `DropOldest` ile sınırlıdır — yavaş okuyan client backpressure yaratmasın diye:

```csharp theme={null}
var channel = Channel.CreateBounded<NotificationEnvelope>(new BoundedChannelOptions(50)
{
    FullMode     = BoundedChannelFullMode.DropOldest,
    SingleReader = true,
    SingleWriter = false,
});
```

<Note>
  Kapasite 50 yeterlidir: sayfa açılışında REST list endpoint'i tam state'i tazelediği için, SSE yalnızca "canlı" delta'ları taşır.
</Note>

## NotificationEnvelope — transport modeli

Broadcaster, Domain entity'si yerine ince bir transport tipi taşır (Infrastructure'ın Application/Domain'e bağımlı kalmaması için):

```csharp theme={null}
public sealed class NotificationEnvelope
{
    public Guid     Id           { get; init; }
    public string?  Code         { get; init; }
    public int      CategoryId   { get; init; }
    public string   Category     { get; init; }
    public int      SeverityId   { get; init; }
    public string   Severity     { get; init; }
    public int?     TargetRoleId { get; init; }   // null = tüm personel
    public string   Title        { get; init; }
    public string   Body         { get; init; }
    public string?  ActionUrl    { get; init; }
    public string?  Payload      { get; init; }
    public DateTime? CreatedAt   { get; init; }
}
```

## HMAC stream-token

`EventSource` (tarayıcı SSE API'si) `Authorization: Bearer` header gönderemez. Bu nedenle frontend, mevcut Keycloak token'ı ile kısa-ömürlü bir capability token alır ve SSE bağlantısını **query param** ile açar. Token üretici `HmacStreamTokenService`'tir:

```csharp theme={null}
public interface IAdminNotificationStreamTokenService
{
    string Issue(Guid userId, IReadOnlyList<int> roleIds);
    StreamTokenPrincipal? Validate(string token);
}
```

Token formatı `base64url(payload).base64url(hmac)` — JWT'ye benzer ama header yok (tek algoritma, HMAC-SHA256). Payload `{"sub":"...","roles":[1,2],"exp":...}` taşır. Doğrulamada imza `FixedTimeEquals` ile sabit-zamanlı karşılaştırılır ve `exp` kontrol edilir.

<Warning>
  `StreamTokenSecret` boş ya da 32 karakterden kısa ise servis **runtime'da rastgele 64 byte** anahtar üretir ve uyarı loglar. Bu yalnızca geliştirme/test için kabul edilebilir; multi-instance prod'da paylaşılan secret zorunludur (aksi halde her instance farklı anahtarla token üretir/doğrular).
</Warning>

Bu token **yalnızca** SSE stream'ini açar; REST endpoint'lerini açamaz — sızıntı riskini azaltır (Keycloak access token kullanılmaz).

## Application tarafı — AdminNotification aggregate

Bildirim oluşturmanın tek giriş noktası Application katmanındaki `IAdminNotificationService.DispatchAsync`'tir. Domain event handler'lar (MediatR `INotificationHandler`) bunu inject eder:

```csharp theme={null}
public interface IAdminNotificationService
{
    Task<Guid> DispatchAsync(AdminNotification notification, CancellationToken cancellationToken = default);
}
```

İmplementasyon, `AdminNotification` aggregate'ini aynı UnitOfWork ile DB'ye yazar; `SaveEntitiesAsync` sonrası `NotificationEnvelope` üretip broadcaster'a push eder (bağlı client'lar anında alır). Örnek tetikleyiciler: `NotifyAdminsUserInvitedDomainEventHandler`, `NotifyAdminsEtkinlikKontenjanDolduDomainEventHandler`, `NotifyAdminsCitizenAccountDeactivatedDomainEventHandler`.

## Heartbeat

SSE bağlantısının canlı kalması için periyodik comment-line gönderilir. Frekans `StreamHeartbeatSeconds` (default 25 sn) ile ayarlanır ve nginx `proxy_read_timeout` (default 60 sn) altında kalmalıdır. Controller bunu dar bir `INotificationStreamOptions` abstraction'ı üzerinden okur.

## Multi-instance — henüz desteklenmiyor

`InMemoryBroadcaster: false` ayarı multi-instance (Redis backplane) senaryosu içindir ama Redis impl'i **henüz yoktur**. Startup'ta bilinçli olarak `NotSupportedException` fırlatılır (fail-fast):

```csharp theme={null}
if (settings.InMemoryBroadcaster)
    services.AddSingleton<IAdminNotificationBroadcaster, InMemoryAdminNotificationBroadcaster>();
else
    throw new NotSupportedException(
        "Services:Notification.InMemoryBroadcaster=false için Redis backplane impl'i henüz eklenmedi. ...");
```

## Config — `Services:Notification`

```json theme={null}
{
  "Services": {
    "Notification": {
      "InMemoryBroadcaster": true,
      "StreamHeartbeatSeconds": 25,
      "StreamTokenSecret": "dev-only-stream-token-secret-change-in-prod-min-32-chars",
      "StreamTokenLifetimeSeconds": 300
    }
  }
}
```

<ParamField path="InMemoryBroadcaster" type="bool" default="true">`true` → in-memory; `false` → (NYI) Redis backplane, startup'ta exception.</ParamField>
<ParamField path="StreamHeartbeatSeconds" type="int" default="25">SSE heartbeat frekansı (proxy timeout altında kalmalı).</ParamField>
<ParamField path="StreamTokenSecret" type="string">HMAC anahtarı, min 32 karakter. Boş → runtime rastgele (sadece dev).</ParamField>
<ParamField path="StreamTokenLifetimeSeconds" type="int" default="300">Stream-token ömrü (saniye).</ParamField>

## DI kaydı

```csharp theme={null}
public static IServiceCollection AddNotificationService(this IServiceCollection services, IConfiguration configuration)
{
    services.Configure<AdminNotificationSettings>(o =>
        configuration.GetSection("Services:Notification").Bind(o));

    var settings = new AdminNotificationSettings();
    configuration.GetSection("Services:Notification").Bind(settings);

    if (settings.InMemoryBroadcaster)
        services.AddSingleton<IAdminNotificationBroadcaster, InMemoryAdminNotificationBroadcaster>();
    else
        throw new NotSupportedException(/* Redis backplane NYI */);

    services.AddSingleton<IAdminNotificationStreamTokenService, HmacStreamTokenService>();
    services.AddSingleton<INotificationStreamOptions, NotificationStreamOptions>();

    return services;
}
```

<CardGroup cols={2}>
  <Card title="Domain Events" icon="bolt" href="/domain/domain-events">
    Bildirimleri tetikleyen domain event'ler ve handler'lar.
  </Card>

  <Card title="Caching" icon="database" href="/caching/overview">
    Multi-instance senaryosunda kullanılan Redis backplane deseni.
  </Card>
</CardGroup>
