Notication problemleri giderildi ve Permission Order

This commit is contained in:
Sedat ÖZTÜRK 2026-09-20 23:23:39 +03:00
parent d21e87c26f
commit 969b048946
45 changed files with 1998 additions and 645 deletions

View file

@ -369,7 +369,7 @@ on the calling tenant's own database. Tenant users are read-only over MCP (schem
request to "let an agent query/update X" is a Decision Flow §1 (ListForm) request first — MCP request to "let an agent query/update X" is a Decision Flow §1 (ListForm) request first — MCP
only gates which of the existing screens/queries an already-authorized client may reach; it only gates which of the existing screens/queries an already-authorized client may reach; it
never grants access beyond the calling user's own ListForm/query permissions. Ayrıntı: never grants access beyond the calling user's own ListForm/query permissions. Ayrıntı:
`README.md` §13.6, `CLAUDE.md`'nin `/mcp` cross-cutting maddeleri. `README.md` §13.9, `CLAUDE.md`'nin `/mcp` cross-cutting maddeleri.
--- ---

View file

@ -1189,7 +1189,7 @@ soneklerini alır.
| **Developer Kit › Components** | `App.DeveloperKit.CustomComponents` · `/admin/list/App.DeveloperKit.CustomComponents` | `Sas_H_CustomComponent` | host | Custom component listesi (ListForm) ve satırdaki **Design** butonuyla **Visual Designer** girişi (§10). Kayıtlar `data/App.DeveloperKit.CustomComponents.json` dosyasına senkronlanır. | | **Developer Kit › Components** | `App.DeveloperKit.CustomComponents` · `/admin/list/App.DeveloperKit.CustomComponents` | `Sas_H_CustomComponent` | host | Custom component listesi (ListForm) ve satırdaki **Design** butonuyla **Visual Designer** girişi (§10). Kayıtlar `data/App.DeveloperKit.CustomComponents.json` dosyasına senkronlanır. |
| **Developer Kit › ListForm** | `App.Listforms.Listform` | `Sas_H_ListForm` · `Sas_H_ListFormField` · `Sas_H_ListFormWorkflow` · `Sas_H_ListFormCustomization` · `Sas_H_ListFormImport` | host | Ekran tanımlarının kendisi. Bu dosyada anlatılan bütün JSON kolonlarının canlı hâli; düzenleyicisi `/admin/listform/edit/{kod}`. | | **Developer Kit › ListForm** | `App.Listforms.Listform` | `Sas_H_ListForm` · `Sas_H_ListFormField` · `Sas_H_ListFormWorkflow` · `Sas_H_ListFormCustomization` · `Sas_H_ListFormImport` | host | Ekran tanımlarının kendisi. Bu dosyada anlatılan bütün JSON kolonlarının canlı hâli; düzenleyicisi `/admin/listform/edit/{kod}`. |
| **Developer Kit › Wizard Manager** | menü kodu `App.Listforms.WizardManager` · yetki `App.Listforms.Wizard` · `/admin/listform/wizardManager` | — | host | Wizard seed dosyalarının listesi, düzenlemesi, silinmesi, export/import'u. Sihirbazın kendisi ayrı bir rotadadır: `/admin/listform/wizard`. | | **Developer Kit › Wizard Manager** | menü kodu `App.Listforms.WizardManager` · yetki `App.Listforms.Wizard` · `/admin/listform/wizardManager` | — | host | Wizard seed dosyalarının listesi, düzenlemesi, silinmesi, export/import'u. Sihirbazın kendisi ayrı bir rotadadır: `/admin/listform/wizard`. |
| **Developer Kit › MCP Servers** | `App.DeveloperKit.McpServers` · `/admin/list/App.DeveloperKit.McpServers` | `Sas_H_McpServer` | host | `/mcp` ucunun kiracı bazlı sözleşmesi: `Name`, `Url`, `Tenants`, `AllowedTools` (boşsa tüm araçlar, doluysa yalnızca seçilenler açık), `MaxRows`. Araç listesi bu kayda göre süzülür, çağrı anında aynı kural yeniden uygulanır. Yetkiler: ekran için `App.DeveloperKit.McpServers`, araç çağırma için `.Invoke`, veri yazma için `.Write`, prompt ile tablo/ekran/bileşen geliştirme (`create_table`, wizard deploy, custom component kaydı) için `.Develop` (host; Wizard ve Custom Components yetkilerini ayrıca ister). Kiracı kullanıcısı yetkisi ne olursa olsun yalnızca okur (`McpToolNames.TenantTools`: şema araçları, `query_list`, `run_select`); yazma, `create_table`, wizard, component ve `run_query` kiracıya kapalıdır. Host için tek sunucu anahtarı `AllowedTools`. MCP yetki vermez: kullanıcı yalnızca uygulamada okuyabildiği listeleri görür ve sorgular; ham SQL `App.SqlQueryManager` ister. Kayıtlar `data/App.DeveloperKit.McpServers.json` dosyasına senkronlanır. Araçlar ve şema keşfinin kaynağı: `README.md` §13.6. | | **Developer Kit › MCP Servers** | `App.DeveloperKit.McpServers` · `/admin/list/App.DeveloperKit.McpServers` | `Sas_H_McpServer` | host | `/mcp` ucunun kiracı bazlı sözleşmesi: `Name`, `Url`, `Tenants`, `AllowedTools` (boşsa tüm araçlar, doluysa yalnızca seçilenler açık), `MaxRows`. Araç listesi bu kayda göre süzülür, çağrı anında aynı kural yeniden uygulanır. Yetkiler: ekran için `App.DeveloperKit.McpServers`, araç çağırma için `.Invoke`, veri yazma için `.Write`, prompt ile tablo/ekran/bileşen geliştirme (`create_table`, wizard deploy, custom component kaydı) için `.Develop` (host; Wizard ve Custom Components yetkilerini ayrıca ister). Kiracı kullanıcısı yetkisi ne olursa olsun yalnızca okur (`McpToolNames.TenantTools`: şema araçları, `query_list`, `run_select`); yazma, `create_table`, wizard, component ve `run_query` kiracıya kapalıdır. Host için tek sunucu anahtarı `AllowedTools`. MCP yetki vermez: kullanıcı yalnızca uygulamada okuyabildiği listeleri görür ve sorgular; ham SQL `App.SqlQueryManager` ister. Kayıtlar `data/App.DeveloperKit.McpServers.json` dosyasına senkronlanır. Araçlar ve şema keşfinin kaynağı: `README.md` §13.9. |
| **Forum Management** | `App.ForumManagement` (grup menüsü) | `Sas_T_ForumCategory` · `Sas_T_ForumTopic` · `Sas_T_ForumPost` | tenant | Altında Categories / Topics / Posts ListForm ekranları; yayın tarafı Administration'daki `/admin/forum`. | | **Forum Management** | `App.ForumManagement` (grup menüsü) | `Sas_T_ForumCategory` · `Sas_T_ForumTopic` · `Sas_T_ForumPost` | tenant | Altında Categories / Topics / Posts ListForm ekranları; yayın tarafı Administration'daki `/admin/forum`. |
| **Forum Categories** | `App.ForumManagement.Categories` · `/admin/list/App.ForumManagement.Categories` | `Sas_T_ForumCategory` | tenant | Forum kategorilerinin ListForm ekranı; veri `configs/seeds/host/data/App.ForumManagement.Categories.json` dosyasından seed edilir. | | **Forum Categories** | `App.ForumManagement.Categories` · `/admin/list/App.ForumManagement.Categories` | `Sas_T_ForumCategory` | tenant | Forum kategorilerinin ListForm ekranı; veri `configs/seeds/host/data/App.ForumManagement.Categories.json` dosyasından seed edilir. |
| **Forum Topics** | `App.ForumManagement.Topics` · `/admin/list/App.ForumManagement.Topics` | `Sas_T_ForumTopic` | tenant | Forum konuları; kategori lookup'ı, sayaçlar ve sabitle/kilitle/çözüldü bayrakları. | | **Forum Topics** | `App.ForumManagement.Topics` · `/admin/list/App.ForumManagement.Topics` | `Sas_T_ForumTopic` | tenant | Forum konuları; kategori lookup'ı, sayaçlar ve sabitle/kilitle/çözüldü bayrakları. |

View file

@ -903,6 +903,14 @@ Route.Authority ──┘ └► Permission
`App.Orders.*`, `App.BlogManagement.*`, `App.IdentityManagement.*`, `App.Reports.*`, `App.Orders.*`, `App.BlogManagement.*`, `App.IdentityManagement.*`, `App.Reports.*`,
`App.Administration`, `App.Setting`, `App.Setup.Migrate`. `App.Administration`, `App.Setting`, `App.Setup.Migrate`.
Yetki ekranındaki **sıralama** `AbpPermissions.Order` ve `AbpPermissionGroups.Order`
sütunlarından gelir (`Modules` gibi genişletme alanları, `PlatformEfCoreEntityExtensionMappings`);
ABP kayıtları sırasız okuduğu için sıra taşınmazsa ekran her açılışta başka bir dizilim gösterirdi.
Sıra **kardeşler arasındadır**: her üst yetkinin altında 1'den başlar, grubun kök yetkileri de
kendi aralarında 1'den. `PlatformPermissionAppService` sıralama anahtarını kökten yaprağa üst yetki
zincirinden kurar, böylece çocuk her zaman ebeveyninin altında kalır. Değerler seed dosyalarındaki
`Order` alanında tutulur ve Permissions / Permission Groups ekranlarından düzenlenebilir.
Aksiyon yetkisi olan alt gruplar (UI tarafındaki karşılıkları `constants/permission.constant.ts` Aksiyon yetkisi olan alt gruplar (UI tarafındaki karşılıkları `constants/permission.constant.ts`
içindedir; oradaki kontroller yalnızca butonları gizler, asıl kontrol AppService'lerdedir): içindedir; oradaki kontroller yalnızca butonları gizler, asıl kontrol AppService'lerdedir):
@ -931,7 +939,7 @@ içindedir; oradaki kontroller yalnızca butonları gizler, asıl kontrol AppSer
| **Sozsoft.MailQueue** | Şablon (Scriban) tabanlı gövde üretimi, ek dosya yaşam döngüsü, kuyruk çalıştırma ve günlükleme. | | **Sozsoft.MailQueue** | Şablon (Scriban) tabanlı gövde üretimi, ek dosya yaşam döngüsü, kuyruk çalıştırma ve günlükleme. |
| **Sozsoft.Sender** | Amazon SES (mail), Posta Güvercini (SMS), WhatsApp Cloud API, Rocket.Chat göndericileri. | | **Sozsoft.Sender** | Amazon SES (mail), Posta Güvercini (SMS), WhatsApp Cloud API, Rocket.Chat göndericileri. |
| **Sozsoft.SqlQueryManager** | SQL nesne yönetimi, sorgu çalıştırma, tablo/kolon metadata'sı. | | **Sozsoft.SqlQueryManager** | SQL nesne yönetimi, sorgu çalıştırma, tablo/kolon metadata'sı. |
| **Sozsoft.Mcp** | MCP (Model Context Protocol) sunucusu: `/mcp` Streamable HTTP ucu, kiracı bazlı araç seti, şema keşfi, sorgu, veri yazma ve (host) tablo/ekran/bileşen geliştirme. Kiracı kullanıcısı yalnızca veri okur. Bkz. §13.6. | | **Sozsoft.Mcp** | MCP (Model Context Protocol) sunucusu: `/mcp` Streamable HTTP ucu, kiracı bazlı araç seti, şema keşfi, sorgu, veri yazma ve (host) tablo/ekran/bileşen geliştirme. Kiracı kullanıcısı yalnızca veri okur. Bkz. §13.9. |
### 10.2. Uygulama servisleri (`api/src/Sozsoft.Platform.Application`) ### 10.2. Uygulama servisleri (`api/src/Sozsoft.Platform.Application`)
@ -1130,7 +1138,65 @@ Bildirim **tip + kanal** eşleşmesiyle yönlendirilir. Alıcı hedefleme seçen
`All`, `User`, `Role`, `OrganizationUnit`, `Custom`. Kullanıcılar kendi bildirim tercihlerini `All`, `User`, `Role`, `OrganizationUnit`, `Custom`. Kullanıcılar kendi bildirim tercihlerini
`/admin/profile/notification-settings` üzerinden kanal bazında yönetir. `/admin/profile/notification-settings` üzerinden kanal bazında yönetir.
### 13.3. Göndericiler (`Sozsoft.Sender`) ### 13.3. Kayıt durumu: gönderildi → okundu
Bir bildirim kaydı iki bayrak taşır ve sıra değişmez: **önce `IsSent`, sonra `IsRead`**.
Gönderilmemiş bir kayıt okunmuş olamaz.
| Kanal | `IsSent` ne zaman |
| --- | --- |
| `UiActivity` | Kayıt oluşurken (`NotificationManager`); ayrı bir gönderim adımı yoktur, kayıt doğrudan kullanıcının akışına yazılır |
| `UiToast` · `Desktop` | Tarayıcıda gösterildiğinde, UI `updateSentMany` ile işaretler |
| `Mail` · `Sms` · `Rocket` · `WhatsApp` · `Telegram` | `NotificationWorker` sağlayıcıya teslim ettiğinde |
`IsRead` ve `ReadTime` birlikte yazılır (`NotificationAppService.UpdateRead*`): okundu işaretinde
`ReadTime = Clock.Now`, okunmadıya çevirmede `null`. `IsSent = false` olan kayıt okundu
işaretlenmez — tekil ve toplu okuma yolları bu kaydı sessizce atlar, gönderim gerçekleştiğinde
okunabilir olur. Bu yüzden UI işaretlemeden sonra listeyi sunucudan tazeler; iyimser güncelleme
rozette yanlış sayı bırakırdı.
Tarayıcı kanalları tek kümededir (`NotificationChannels.BrowserChannels`); `sent-many` ucu
yalnızca bu kanalları işaretler, böylece istemci Mail/Sms gibi bir kanalı "gönderildi" yapamaz.
Masaüstü bildirimi izin verilmemişse hiç işaretlenmez — işaretlenseydi izin sonradan verildiğinde
de gösterilmezdi.
### 13.4. Gerçek zamanlı iletim
Bildirim üretildiği anda açık istemcilere **SignalR** ile duyurulur: hub `/notificationhub`,
bağlantı kullanıcı başına gruplanır (`NotificationHub.UserGroupName`, kiracı + kullanıcı).
`NotificationManager` kayıtları yazdıktan **sonra** duyurur (`INotificationRealtimeNotifier`);
duyuru yükü yalnızca hangi kanallarda kayıt oluştuğunu söyler, istemci listesini yine sunucudan
okur — okundu/gönderildi bayrakları tek kaynaktan gelir.
Katman sınırı: sözleşme bildirim modülünün Domain katmanındadır (`INotificationRealtimeNotifier`),
SignalR gerçeklemesi ise barındıran uygulamadadır (`Sozsoft.Platform.Application/Notifications/`,
`MessengerHub` ile aynı yer) — modül katmanları ASP.NET Core'a bağlanmaz. Gerçekleme
`[Dependency(ReplaceServices = true)]` ile devreye girer; SignalR olmayan barındırmalarda
(DbMigrator, testler) `NullNotificationRealtimeNotifier` kalır.
Duyuru **en iyi çaba**dır: bağlantı yoksa ya da mesaj kaybolursa bildirim kaybolmaz —
istemcideki yedek yoklama (5 dakika, sekme öne geldiğinde ayrıca) aynı kaydı getirir. Ters
vekilde `/notificationhub` kendi bloğunda WebSocket yükseltmesiyle karşılanır
(`configs/deployment/configs/nginx.conf`); token sorgu dizesinden geldiği için host bu yolu da
`Authorization` başlığına çevirir.
Masaüstü bildirimine tıklamak uygulamaya döner: service worker açık sekmeyi öne alıp bildirimin
`data.url` adresine götürür, sekme yoksa yenisini açar (varsayılan hedef Activity Log).
### 13.5. Kullanıcı tercihleri
Kurallar alıcı kümesini belirler; kullanıcının profilinden yazdığı tercih (`IsCustomized`,
`RecipientType = User`) **kural değildir**, yalnızca sahibinin o tür/kanal aboneliğini kapatır
(`NotificationManager`). Tercihi kural gibi işletmek, bir kullanıcının kendi tercihini yazmasıyla
aynı tür ve kanaldaki herkesin bildirimini kesmesi anlamına gelirdi. `IsFixed` kural zorunludur,
tercih dikkate alınmaz. Aynı kullanıcıyı birden çok kural hedeflerse (All + Role) kanal başına tek
kayıt üretilir.
Tercih ekranı (`/admin/profile/notification-settings`) satırlarını bildirim **tipinin id'si**
üzerinden eşler; tip adı yalnızca gösterimdir. `NotificationType.Description` alanı aynı ekranda
ipucu olarak görünür ve Bildirim Türleri ekranından yazılır.
### 13.6. Göndericiler (`Sozsoft.Sender`)
| Kanal | Sağlayıcı | | Kanal | Sağlayıcı |
| --- | --- | | --- | --- |
@ -1142,12 +1208,12 @@ Bildirim **tip + kanal** eşleşmesiyle yönlendirilir. Alıcı hedefleme seçen
Tüm sağlayıcı kimlik bilgileri ayar ekranından yönetilir (`App.Settings.*` anahtarları); Tüm sağlayıcı kimlik bilgileri ayar ekranından yönetilir (`App.Settings.*` anahtarları);
koda gömülmez. koda gömülmez.
### 13.4. Mail kuyruğu ### 13.7. Mail kuyruğu
`Sozsoft.MailQueue`; şablondan gövde üretir, PDF/XLS/TXT çıktıları ve ek dosyaları yönetir, `Sozsoft.MailQueue`; şablondan gövde üretir, PDF/XLS/TXT çıktıları ve ek dosyaları yönetir,
kuyruğu arka plan işçisiyle çalıştırır ve sonuçları günlükler. kuyruğu arka plan işçisiyle çalıştırır ve sonuçları günlükler.
### 13.5. AI entegrasyonu ### 13.8. AI entegrasyonu
`AiBot` kayıtları tenant bazlıdır; n8n webhook üzerinden bir niyet sınıflandırma adımı ve `AiBot` kayıtları tenant bazlıdır; n8n webhook üzerinden bir niyet sınıflandırma adımı ve
ardından sohbet ya da SQL dalı çalışır (`configs/ai/Chat.json`). Model, DevOps makinesindeki ardından sohbet ya da SQL dalı çalışır (`configs/ai/Chat.json`). Model, DevOps makinesindeki
@ -1160,7 +1226,7 @@ alan adı yönlendirmesi için §20 kullanılır. Akıştaki model adı değişt
içindeki üç varsayılanın (`intentModel`, `chatModel`, `sqlModel`) birlikte güncellenmesi gerekir; içindeki üç varsayılanın (`intentModel`, `chatModel`, `sqlModel`) birlikte güncellenmesi gerekir;
biri kalırsa o dal "model not found" ile düşer. biri kalırsa o dal "model not found" ile düşer.
### 13.6. MCP sunucusu ### 13.9. MCP sunucusu
Yapay zekâ istemcileri (Claude Code, Claude Desktop, ChatGPT) platforma **`/mcp`** ucundan Yapay zekâ istemcileri (Claude Code, Claude Desktop, ChatGPT) platforma **`/mcp`** ucundan
bağlanır. Uç, API imajının içinde aynı portta (`8080`) yayınlanır; ayrı bir container yoktur. bağlanır. Uç, API imajının içinde aynı portta (`8080`) yayınlanır; ayrı bir container yoktur.
@ -1301,7 +1367,8 @@ istemci tarafında görülen hata genellikle "please register the OAuth client"
- API konteynerinde `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` (`docker-compose-app.yml`) - API konteynerinde `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` (`docker-compose-app.yml`)
- nginx'te **her** `location` bloğunun `X-Forwarded-Proto $scheme` göndermesi. `location /` - nginx'te **her** `location` bloğunun `X-Forwarded-Proto $scheme` göndermesi. `location /`
bunu `proxy_params` üzerinden yapar; `/mcp`, `/videoroomhub` ve `/messengerhub` kendi bunu `proxy_params` üzerinden yapar; `/mcp`, `/videoroomhub`, `/messengerhub` ve
`/notificationhub` kendi
blokları olduğu için başlığı ayrıca set eder. Bir blok atlanırsa istemcinin gönderdiği blokları olduğu için başlığı ayrıca set eder. Bir blok atlanırsa istemcinin gönderdiği
başlık olduğu gibi geçer ve şema taklit edilebilir hale gelir. başlık olduğu gibi geçer ve şema taklit edilebilir hale gelir.
@ -1515,7 +1582,7 @@ eklerken portun boş olduğu buradan doğrulanır.
| `ollama.sozsoft.com` | 3003 | Open WebUI — sohbet arayüzü | | `ollama.sozsoft.com` | 3003 | Open WebUI — sohbet arayüzü |
| `ollama.sozsoft.com/ollama-api/` | 11434 | Ollama ham API'si (basic auth) | | `ollama.sozsoft.com/ollama-api/` | 11434 | Ollama ham API'si (basic auth) |
| `ai.sozsoft.com` | 5678 | n8n | | `ai.sozsoft.com` | 5678 | n8n |
| `api.sozsoft.com` | 8080 | API (`/mcp`, `/videoroomhub`, `/messengerhub` dahil) | | `api.sozsoft.com` | 8080 | API (`/mcp`, `/videoroomhub`, `/messengerhub`, `/notificationhub` dahil) |
| `cdn.sozsoft.com` | 8081 | CDN | | `cdn.sozsoft.com` | 8081 | CDN |
Open WebUI kendi backend'ini `/api/...` altında sunduğu için Ollama'nın ham API'si `/ollama-api/` Open WebUI kendi backend'ini `/api/...` altında sunduğu için Ollama'nın ham API'si `/ollama-api/`

View file

@ -12,10 +12,24 @@ public interface INotificationRuleAppService : ICrudAppService<
PagedAndSortedResultRequestDto, PagedAndSortedResultRequestDto,
CreateUpdateNotificationRuleDto> CreateUpdateNotificationRuleDto>
{ {
Task<string[]> GetNotificationTypes(); /// <summary>
Task<string[]> GetMyNotificationTypesAsync(); /// Kullanicinin kurallarina konu olan bildirim tipleri. Tercih ekrani satirlari bu tiplerden
/// kurar; eslesme ad uzerinden degil <c>Id</c> uzerinden yapilir.
/// </summary>
Task<List<NotificationTypeDto>> GetMyNotificationTypesAsync();
Task<List<NotificationRuleDto>> GetMyNotificationRules(); Task<List<NotificationRuleDto>> GetMyNotificationRules();
/// <summary>
/// Elle bildirim diyaloguna kuralin ozetini verir. Parametre adi <c>id</c> olamaz: ABP o adi
/// gorunce ucu <c>{id}</c> rotasina tasir ve cagri CRUD'un Get ucuna duserdi.
/// </summary>
Task<NotificationRuleSummaryDto> GetRuleSummaryAsync(Guid ruleId);
/// <summary>Kullanicinin bir tur/kanal icin kendi tercihini yazar.</summary>
Task PostMyNotificationRule(CreateUpdateNotificationRuleDto Input); Task PostMyNotificationRule(CreateUpdateNotificationRuleDto Input);
/// <summary>Bir turdeki kullanici tercihlerini siler; kural yeniden varsayilana doner.</summary>
Task DeleteMyNotificationRules(Guid NotificationTypeId); Task DeleteMyNotificationRules(Guid NotificationTypeId);
} }

View file

@ -0,0 +1,22 @@
using System;
namespace Sozsoft.Notifications.NotificationRules;
/// <summary>
/// Bir kuralin elle bildirim diyalogunda gosterilen ozeti: hangi olay, kime, hangi kanaldan.
/// Diyalog yalnizca kural kimligini aldigi icin bu bilgiyi sunucudan okur.
/// </summary>
public class NotificationRuleSummaryDto
{
public Guid Id { get; set; }
public string NotificationTypeName { get; set; } = string.Empty;
public string Channel { get; set; } = string.Empty;
public string RecipientType { get; set; } = string.Empty;
public string? RecipientId { get; set; }
public bool IsActive { get; set; }
}

View file

@ -6,5 +6,8 @@ namespace Sozsoft.Notifications.NotificationRules;
public class NotificationTypeDto : FullAuditedEntityDto<Guid> public class NotificationTypeDto : FullAuditedEntityDto<Guid>
{ {
public string Name { get; set; } public string Name { get; set; }
/// <summary>Turun ne ise yaradigi; tercih ekraninda ipucu olarak gosterilir.</summary>
public string? Description { get; set; }
} }

View file

@ -15,6 +15,10 @@ using Volo.Abp.Identity;
namespace Sozsoft.Notifications.Application; namespace Sozsoft.Notifications.Application;
/// <summary>
/// Kullanicinin kendi bildirimleri: liste, okundu isaretleme ve tarayici kanallarinda gosterim
/// isareti. Her sorgu <c>UserId</c> ile kendi kayitlarina kapalidir.
/// </summary>
[Authorize] [Authorize]
public class NotificationAppService : ReadOnlyAppService< public class NotificationAppService : ReadOnlyAppService<
Notification, Notification,
@ -39,30 +43,14 @@ public class NotificationAppService : ReadOnlyAppService<
this.repositoryUser = repositoryUser; this.repositoryUser = repositoryUser;
} }
public async Task GetTestAsync() /// <summary>Bir kuraldan elle bildirim uretir; Bildirim Kurallari ekranindaki "Create" komutu.</summary>
{
// await notificationManager.SendNotificationAsync(NotificationTypes.SiparisPasla, "Sayin kullanici, nasilsiniz?");
// await notificationManager.SendNotificationAsync(NotificationTypes.YeniKullanici, "Sayin kullanici, nasilsiniz?");
// await notificationManager.SendNotificationAsync(NotificationTypes.YeniSiparis, "Sayin kullanici, nasilsiniz?");
//smsgönder
// alıcılar: +1435345134, +1435345134
// mesaj: Sayin kullanici, nasilsiniz?
// await notificationManager.SendNotificationAsync(
// NotificationTypes.SiparisPasla,
// NotificationChannels.WhatsApp,
// "Sayin kullanici, nasilsiniz?",
// "+905449476346");
}
public async Task CreateNotificationByNotificationRuleIdAsync(NotificationRequestDto input) public async Task CreateNotificationByNotificationRuleIdAsync(NotificationRequestDto input)
{ {
var notificationRule = await repositoryRule.GetAsync(input.Id, true); var notificationRule = await repositoryRule.FindAsync(input.Id)
if (notificationRule != null) ?? throw new EntityNotFoundException(L["RecordNotFound"]);
{
await notificationManager.SendNotificationAsync(notificationRule.NotificationTypeId, input.Message); await notificationManager.SendNotificationAsync(notificationRule.NotificationTypeId, input.Message);
} }
}
protected override async Task<IQueryable<Notification>> CreateFilteredQueryAsync(NotificationFilterRequestDto input) protected override async Task<IQueryable<Notification>> CreateFilteredQueryAsync(NotificationFilterRequestDto input)
{ {
@ -91,10 +79,10 @@ public class NotificationAppService : ReadOnlyAppService<
foreach (var entity in entities) foreach (var entity in entities)
{ {
var dto = await MapToGetListOutputDtoAsync(entity); var dto = await MapToGetListOutputDtoAsync(entity);
if (entity.CreatorId.HasValue && users.ContainsKey(entity.CreatorId.Value)) if (entity.CreatorId.HasValue && users.TryGetValue(entity.CreatorId.Value, out var user))
{ {
dto.CreatorFullname = users[entity.CreatorId.Value].Fullname; dto.CreatorFullname = user.Fullname;
dto.TenantId = users[entity.CreatorId.Value].TenantId; dto.TenantId = user.TenantId;
} }
dtos.Add(dto); dtos.Add(dto);
} }
@ -102,57 +90,26 @@ public class NotificationAppService : ReadOnlyAppService<
return dtos; return dtos;
} }
public async Task<NotificationDto> UpdateReadAsync(Guid notificationId, bool isRead) /// <summary>
{ /// Bir kanaldaki tum kayitlari okundu/okunmadi yapar. Kullaniciya henuz iletilmemis kayit
var query = await repository.GetQueryableAsync(); /// okunamaz; isaretleme onlari atlar ve gonderim gerceklestiginde tekrar okunabilir olurlar.
/// </summary>
var item = await query
.Include(a => a.NotificationType)
.FirstOrDefaultAsync(a =>
a.UserId == CurrentUser.Id &&
a.Id == notificationId);
if (item == null)
{
throw new EntityNotFoundException(L["RecordNotFound"]);
}
item.IsRead = isRead;
await repository.UpdateAsync(item, autoSave: true);
return ObjectMapper.Map<Notification, NotificationDto>(item);
}
public async Task<int> UpdateReadAllAsync(string notificationChannel, bool isRead) public async Task<int> UpdateReadAllAsync(string notificationChannel, bool isRead)
{ {
var query = await repository.GetQueryableAsync(); var query = await repository.GetQueryableAsync();
var affectedRows = await query.Where(a => a.UserId == CurrentUser.Id && a.NotificationChannel == notificationChannel && a.IsRead == !isRead) var readTime = isRead ? Clock.Now : (DateTime?)null;
.ExecuteUpdateAsync(b => var affectedRows = await query
b.SetProperty(u => u.IsRead, isRead) .Where(a => a.UserId == CurrentUser.Id && a.NotificationChannel == notificationChannel && a.IsRead == !isRead)
.Where(a => !isRead || a.IsSent)
.ExecuteUpdateAsync(b => b
.SetProperty(u => u.IsRead, isRead)
.SetProperty(u => u.ReadTime, readTime)
); );
return affectedRows; return affectedRows;
} }
public async Task<NotificationDto> UpdateSentAsync(Guid notificationId, bool isSent) /// <summary>Secili kayitlari okundu/okunmadi yapar; kural <see cref="UpdateReadAllAsync"/> ile aynidir.</summary>
{
var query = await repository.GetQueryableAsync();
var item = await query
.Include(a => a.NotificationType)
.FirstOrDefaultAsync(a =>
a.UserId == CurrentUser.Id &&
a.Id == notificationId);
if (item == null)
{
throw new EntityNotFoundException(L["RecordNotFound"]);
}
item.IsSent = isSent;
await repository.UpdateAsync(item, autoSave: true);
return ObjectMapper.Map<Notification, NotificationDto>(item);
}
public async Task<int> UpdateReadManyAsync(List<Guid> notificationIds, bool isRead) public async Task<int> UpdateReadManyAsync(List<Guid> notificationIds, bool isRead)
{ {
if (notificationIds.Count == 0) if (notificationIds.Count == 0)
@ -161,11 +118,20 @@ public class NotificationAppService : ReadOnlyAppService<
} }
var query = await repository.GetQueryableAsync(); var query = await repository.GetQueryableAsync();
var readTime = isRead ? Clock.Now : (DateTime?)null;
return await query return await query
.Where(a => a.UserId == CurrentUser.Id && notificationIds.Contains(a.Id) && a.IsRead != isRead) .Where(a => a.UserId == CurrentUser.Id && notificationIds.Contains(a.Id) && a.IsRead != isRead)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsRead, isRead)); .Where(a => !isRead || a.IsSent)
.ExecuteUpdateAsync(setters => setters
.SetProperty(a => a.IsRead, isRead)
.SetProperty(a => a.ReadTime, readTime));
} }
/// <summary>
/// Tarayici kanallarinda (toast, masaustu) gosterim isaretidir; gosterilen kayit iletilmis
/// sayilir. Diger kanallarin gonderimi <c>NotificationWorker</c>'a aittir.
/// </summary>
public async Task<int> UpdateSentManyAsync(List<Guid> notificationIds, bool isSent) public async Task<int> UpdateSentManyAsync(List<Guid> notificationIds, bool isSent)
{ {
if (notificationIds.Count == 0) if (notificationIds.Count == 0)
@ -176,6 +142,7 @@ public class NotificationAppService : ReadOnlyAppService<
var query = await repository.GetQueryableAsync(); var query = await repository.GetQueryableAsync();
return await query return await query
.Where(a => a.UserId == CurrentUser.Id && notificationIds.Contains(a.Id) && a.IsSent != isSent) .Where(a => a.UserId == CurrentUser.Id && notificationIds.Contains(a.Id) && a.IsSent != isSent)
.Where(a => NotificationChannels.BrowserChannels.Contains(a.NotificationChannel))
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsSent, isSent)); .ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsSent, isSent));
} }
@ -184,4 +151,3 @@ public class NotificationAppService : ReadOnlyAppService<
[RemoteService(false)] [RemoteService(false)]
protected override Task<Notification> GetEntityByIdAsync(Guid id) => throw new NotImplementedException(); protected override Task<Notification> GetEntityByIdAsync(Guid id) => throw new NotImplementedException();
} }

View file

@ -8,6 +8,7 @@ using Sozsoft.Notifications.Domain;
using Sozsoft.Notifications.Entities; using Sozsoft.Notifications.Entities;
using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Entities;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
using Volo.Abp.Identity; using Volo.Abp.Identity;
@ -22,38 +23,27 @@ public class NotificationRuleAppService : CrudAppService<
CreateUpdateNotificationRuleDto>, CreateUpdateNotificationRuleDto>,
INotificationRuleAppService INotificationRuleAppService
{ {
private readonly IRepository<NotificationType, Guid> repositoryType;
private readonly IIdentityUserRepository repositoryUser; private readonly IIdentityUserRepository repositoryUser;
public NotificationRuleAppService( public NotificationRuleAppService(
IRepository<NotificationType, Guid> repositoryType,
IRepository<NotificationRule, Guid> repository, IRepository<NotificationRule, Guid> repository,
IIdentityUserRepository repositoryUser IIdentityUserRepository repositoryUser
) : base(repository) ) : base(repository)
{ {
this.repositoryType = repositoryType;
this.repositoryUser = repositoryUser; this.repositoryUser = repositoryUser;
} }
public async Task<string[]> GetNotificationTypes() /// <summary>
/// Birimin kendi kodu ve tum ust birim kodlari. Ust birime tanimlanan kural alt birimlere de
/// isledigi icin (<c>NotificationManager</c> birimi cocuklariyla birlikte cozer) kullanicinin
/// gorecegi kurallar bu zincirin tamamindan gelir.
/// </summary>
private static void AddUnitCodeWithParents(string? code, List<string> codes)
{ {
var types = await repositoryType.GetListAsync(); while (!code.IsNullOrWhiteSpace())
return types.Select(a => a.Name).ToArray();
}
private void GetAllParentCodes(string code, List<string>? codes)
{ {
codes ??= []; codes.Add(code!);
var parentCode = OrganizationUnit.GetParentCode(code); code = OrganizationUnit.GetParentCode(code!);
codes.Add(parentCode);
if (parentCode is null)
{
return;
}
else
{
GetAllParentCodes(parentCode, codes);
} }
} }
@ -62,27 +52,54 @@ public class NotificationRuleAppService : CrudAppService<
// Bir kullanıcının ait olduğu organization unit'lerin her bir parentının kodu lazım // Bir kullanıcının ait olduğu organization unit'lerin her bir parentının kodu lazım
// Böylece bir kullanıcının üst birimleri de dahil alabileceği tüm bildirim tiplerini listeleyebileceğiz // Böylece bir kullanıcının üst birimleri de dahil alabileceği tüm bildirim tiplerini listeleyebileceğiz
var organizationUnits = await repositoryUser.GetOrganizationUnitsAsync(CurrentUser.Id!.Value); var organizationUnits = await repositoryUser.GetOrganizationUnitsAsync(CurrentUser.Id!.Value);
var organizationUnitCodes = organizationUnits.Select(a => a.Code).ToList(); //01.02.03, 43.44.45
var ouCodes = new List<string>(); var ouCodes = new List<string>();
foreach (var code in organizationUnitCodes) foreach (var unit in organizationUnits)
{ {
GetAllParentCodes(code, ouCodes); AddUnitCodeWithParents(unit.Code, ouCodes); //01.02.03 -> 01.02.03, 01.02, 01
} }
ouCodes = ouCodes.Distinct().ToList(); ouCodes = [.. ouCodes.Distinct()];
var query = await base.CreateFilteredQueryAsync(input); var query = await base.CreateFilteredQueryAsync(input);
return query.Where(a => return query.Where(a =>
a.RecipientType == NotificationRecipientTypes.User && a.RecipientId == CurrentUser.UserName || a.RecipientType == NotificationRecipientTypes.User && a.RecipientId == CurrentUser.UserName ||
a.RecipientType == NotificationRecipientTypes.Role && CurrentUser.Roles.Contains(a.RecipientId) || a.RecipientType == NotificationRecipientTypes.Role && CurrentUser.Roles.Contains(a.RecipientId) ||
a.RecipientType == NotificationRecipientTypes.OrganizationUnit && ouCodes.Contains(a.RecipientId) || a.RecipientType == NotificationRecipientTypes.OrganizationUnit && a.RecipientId != null && ouCodes.Contains(a.RecipientId) ||
a.RecipientType == NotificationRecipientTypes.All); a.RecipientType == NotificationRecipientTypes.All);
} }
public async Task<string[]> GetMyNotificationTypesAsync() public async Task<List<NotificationTypeDto>> GetMyNotificationTypesAsync()
{ {
var query = await CreateFilteredQueryAsync(new PagedAndSortedResultRequestDto()); var query = await CreateFilteredQueryAsync(new PagedAndSortedResultRequestDto());
return await query.Select(a => a.NotificationType.Name).Distinct().ToArrayAsync(); return await query
.Select(a => new NotificationTypeDto
{
Id = a.NotificationTypeId,
Name = a.NotificationType.Name,
Description = a.NotificationType.Description
})
.Distinct()
.OrderBy(a => a.Name)
.ToListAsync();
}
public async Task<NotificationRuleSummaryDto> GetRuleSummaryAsync(Guid ruleId)
{
var query = await Repository.GetQueryableAsync();
return await query
.Where(rule => rule.Id == ruleId)
.Select(rule => new NotificationRuleSummaryDto
{
Id = rule.Id,
NotificationTypeName = rule.NotificationType.Name,
Channel = rule.Channel,
RecipientType = rule.RecipientType,
RecipientId = rule.RecipientId,
IsActive = rule.IsActive
})
.FirstOrDefaultAsync()
?? throw new EntityNotFoundException(typeof(NotificationRule), ruleId);
} }
public async Task<List<NotificationRuleDto>> GetMyNotificationRules() public async Task<List<NotificationRuleDto>> GetMyNotificationRules()

View file

@ -10,4 +10,11 @@ public static class NotificationChannels
public const string UiToast = "UiToast"; //UI Toast (ABP UI Toast) Ayarlar/Sistem/Bildirimler/Chrome açık olması gerekiyor. public const string UiToast = "UiToast"; //UI Toast (ABP UI Toast) Ayarlar/Sistem/Bildirimler/Chrome açık olması gerekiyor.
public const string WhatsApp = "WhatsApp"; //WhatsApp (HTTP API, template-based) public const string WhatsApp = "WhatsApp"; //WhatsApp (HTTP API, template-based)
public const string Telegram = "Telegram"; //Telegram (Bot API, chat based) public const string Telegram = "Telegram"; //Telegram (Bot API, chat based)
/// <summary>
/// Gonderimi tarayicida gerceklesen kanallar: kayit kullaniciya ancak gosterildiginde
/// iletilmis sayilir, bu yuzden <c>IsSent</c> isaretini istemci atar. Diger kanallari
/// <c>NotificationWorker</c> isaretler; UiActivity ise kayit olusurken iletilmis sayilir.
/// </summary>
public static readonly string[] BrowserChannels = [UiToast, Desktop];
} }

View file

@ -1,10 +0,0 @@
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Sozsoft.Notifications.Domain.NotificationProviders;
public interface INotification : ITransientDependency
{
Task Send(string[] NotificationType, string NotificationMessage);
}

View file

@ -8,6 +8,12 @@ public class NotificationType : FullAuditedEntity<Guid>
{ {
public required string Name { get; set; } public required string Name { get; set; }
/// <summary>
/// Turun ne ise yaradigi: hangi olayda tetiklendigi, kimin ne bekledigi. Kural tanimlayan
/// kisi ekranda yalnizca kod adini gorur; aciklama olmadan bir turun ne oldugu tahmin edilir.
/// </summary>
public string? Description { get; set; }
public virtual ICollection<NotificationRule> NotificationRules { get; set; } = []; public virtual ICollection<NotificationRule> NotificationRules { get; set; } = [];
public ICollection<Notification> Notifications { get; set; } = []; public ICollection<Notification> Notifications { get; set; } = [];
} }

View file

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Sozsoft.Notifications.Domain;
/// <summary>
/// Yeni uretilen bildirimleri acik istemcilere aninda duyurur. Sunum kanalidir (SignalR), bu
/// yuzden gerceklemesi Application katmanindadir; uretim tarafi yalnizca bu sozlesmeyi bilir.
/// </summary>
public interface INotificationRealtimeNotifier
{
/// <summary>Duyuru kayit yazildiktan sonra yapilir; iletim basarisiz olursa uretim etkilenmez.</summary>
Task NotifyAsync(IReadOnlyCollection<NotificationDelivery> deliveries);
}
/// <param name="UserId">Bildirimin sahibi; duyuru yalnizca bu kullanicinin baglantilarina gider.</param>
/// <param name="Channel">Kanal adi; istemci hangi gosterimi yapacagina buna bakarak karar verir.</param>
public sealed record NotificationDelivery(Guid UserId, string Channel);
/// <summary>
/// Gercek zamanli duyuru yalnizca web sunucusunda anlamlidir. SignalR olmayan barindirmalarda
/// (DbMigrator, testler) bu varsayilan kullanilir; Application katmani kendi gerceklemesiyle
/// bunun yerine gecer.
/// </summary>
public class NullNotificationRealtimeNotifier : INotificationRealtimeNotifier, ITransientDependency
{
public Task NotifyAsync(IReadOnlyCollection<NotificationDelivery> deliveries) => Task.CompletedTask;
}

View file

@ -4,85 +4,148 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Sozsoft.Notifications.Entities; using Sozsoft.Notifications.Entities;
using Sozsoft.Notifications.Enums;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
using Volo.Abp.Identity; using Volo.Abp.Identity;
using Volo.Abp.Users;
namespace Sozsoft.Notifications.Domain; namespace Sozsoft.Notifications.Domain;
public interface INotificationManager : ITransientDependency public interface INotificationManager : ITransientDependency
{ {
/// <summary>
/// Bir bildirim tipinin tum kurallarini isletir ve her alici icin kanal basina bir bildirim
/// kaydi uretir. Kayitlarin kanaldan cikisi <c>NotificationWorker</c>'a aittir.
/// </summary>
Task SendNotificationAsync(Guid NotificationTypeId, string NotificationMessage); Task SendNotificationAsync(Guid NotificationTypeId, string NotificationMessage);
Task SendNotificationAsync(string NotificationType, string NotificationChannel, string NotificationMessage, string Identifier, Guid? UserId = null);
} }
public class NotificationManager : INotificationManager public class NotificationManager : INotificationManager
{ {
private readonly IReadOnlyRepository<NotificationType> repositoryType;
private readonly IReadOnlyRepository<NotificationRule> repository; private readonly IReadOnlyRepository<NotificationRule> repository;
private readonly IReadOnlyRepository<IdentityUser> repositoryUser; private readonly IReadOnlyRepository<IdentityUser> repositoryUser;
private readonly IReadOnlyRepository<IdentityRole> repositoryRole; private readonly IReadOnlyRepository<IdentityRole> repositoryRole;
private readonly IRepository<Notification, Guid> repositoryNotification; private readonly IRepository<Notification, Guid> repositoryNotification;
private readonly INotificationIdentifierProvider notificationIdentifierProvider; private readonly INotificationIdentifierProvider notificationIdentifierProvider;
private readonly IOrganizationUnitRepository repositoryOrganizationUnit; private readonly IOrganizationUnitRepository repositoryOrganizationUnit;
private readonly ICurrentUser currentUser; private readonly INotificationRealtimeNotifier realtimeNotifier;
public NotificationManager( public NotificationManager(
IReadOnlyRepository<NotificationType> repositoryType,
IReadOnlyRepository<NotificationRule> repository, IReadOnlyRepository<NotificationRule> repository,
IReadOnlyRepository<IdentityUser> repositoryUser, IReadOnlyRepository<IdentityUser> repositoryUser,
IReadOnlyRepository<IdentityRole> repositoryRole, IReadOnlyRepository<IdentityRole> repositoryRole,
IRepository<Notification, Guid> repositoryNotification, IRepository<Notification, Guid> repositoryNotification,
INotificationIdentifierProvider notificationIdentifierProvider, INotificationIdentifierProvider notificationIdentifierProvider,
IOrganizationUnitRepository repositoryOrganizationUnit, IOrganizationUnitRepository repositoryOrganizationUnit,
ICurrentUser currentUser INotificationRealtimeNotifier realtimeNotifier
) )
{ {
this.repositoryType = repositoryType;
this.repository = repository; this.repository = repository;
this.repositoryUser = repositoryUser; this.repositoryUser = repositoryUser;
this.repositoryRole = repositoryRole; this.repositoryRole = repositoryRole;
this.repositoryNotification = repositoryNotification; this.repositoryNotification = repositoryNotification;
this.notificationIdentifierProvider = notificationIdentifierProvider; this.notificationIdentifierProvider = notificationIdentifierProvider;
this.repositoryOrganizationUnit = repositoryOrganizationUnit; this.repositoryOrganizationUnit = repositoryOrganizationUnit;
this.currentUser = currentUser; this.realtimeNotifier = realtimeNotifier;
} }
public async Task SendNotificationAsync(Guid NotificationTypeId, string NotificationMessage) public async Task SendNotificationAsync(Guid NotificationTypeId, string NotificationMessage)
{ {
var list = await repository.GetListAsync(a => a.NotificationTypeId == NotificationTypeId); var rules = await repository.GetListAsync(a => a.NotificationTypeId == NotificationTypeId);
var notificationRules = list
.GroupBy(a => new { a.NotificationType, a.Channel })
.Select(a =>
{
var item = a.FirstOrDefault(a => a.IsFixed)
?? a.FirstOrDefault(a => a.IsCustomized)
?? a.FirstOrDefault(a => a.IsActive)
?? a.First();
return item;
})
.Where(a => a.IsActive)
.ToList();
// Kullanici tercihleri (profil ekranindan yazilan IsCustomized kayitlari) alici kumesini
// belirlemez; yalnizca sahibinin o kanaldaki aboneligini kapatir. Tercihi kural gibi
// isletmek, bir kullanicinin kendi tercihini yazmasiyla ayni tur/kanaldaki herkesin
// bildirimini kesmesi anlamina gelirdi.
var optedOut = rules
.Where(rule => rule.IsCustomized
&& !rule.IsActive
&& rule.RecipientType == NotificationRecipientTypes.User
&& rule.RecipientId != null)
.Select(rule => (rule.Channel, UserName: rule.RecipientId!))
.ToHashSet();
// Ayni kullaniciyi birden fazla kural hedefleyebilir (All + Role gibi); kanal basina tek
// kayit uretilir.
var delivered = new HashSet<(string Channel, string Identifier)>();
var notifications = new List<Notification>();
var queryUsers = await repositoryUser.GetQueryableAsync(); var queryUsers = await repositoryUser.GetQueryableAsync();
foreach (var notificationRule in notificationRules)
foreach (var notificationRule in rules.Where(rule => rule.IsActive && !rule.IsCustomized))
{
// "Custom" alicida RecipientId adresin kendisidir (telefon, e-posta, chat id);
// sistemde bir kullaniciya karsilik gelmeyebilir, bu yuzden kullanici aramasi yapilmaz.
if (notificationRule.RecipientType == NotificationRecipientTypes.Custom)
{
if (notificationRule.RecipientId.IsNullOrWhiteSpace()
|| !delivered.Add((notificationRule.Channel, notificationRule.RecipientId!)))
{
continue;
}
notifications.Add(BuildNotification(notificationRule, notificationRule.RecipientId!, userId: null, NotificationMessage));
continue;
}
var userIds = await ResolveRecipientUserIdsAsync(notificationRule, queryUsers);
if (userIds.Count == 0)
{
continue;
}
var users = await queryUsers
.Where(user => userIds.Contains(user.Id))
.Select(user => new { user.Id, user.UserName })
.ToListAsync();
foreach (var user in users)
{
// Zorunlu bildirimde (IsFixed) kullanici tercihi dikkate alinmaz.
if (!notificationRule.IsFixed && optedOut.Contains((notificationRule.Channel, user.UserName)))
{
continue;
}
var identifier = await notificationIdentifierProvider.GetIdentifier(user.Id, notificationRule.Channel);
if (identifier.IsNullOrWhiteSpace() || !delivered.Add((notificationRule.Channel, identifier!)))
{
continue;
}
notifications.Add(BuildNotification(notificationRule, identifier!, user.Id, NotificationMessage));
}
}
if (notifications.Count == 0)
{
return;
}
await repositoryNotification.InsertManyAsync(notifications, autoSave: true);
// Duyuru kayit yazildiktan sonra yapilir; istemci duyuruyu alip listesini okudugunda
// kaydi mutlaka gormeli. Duyuru gitmezse yedek yoklama ayni kaydi getirir.
await realtimeNotifier.NotifyAsync(
[.. notifications
.Where(notification => notification.UserId.HasValue)
.Select(notification => new NotificationDelivery(notification.UserId!.Value, notification.NotificationChannel))]);
}
/// <summary>Kuralin hedefledigi kullanicilar; <c>Custom</c> alici bu yoldan gecmez.</summary>
private async Task<List<Guid>> ResolveRecipientUserIdsAsync(NotificationRule notificationRule, IQueryable<IdentityUser> queryUsers)
{ {
List<Guid> userIds = []; List<Guid> userIds = [];
// RecipientType bakarak ilgilileri bul
if (notificationRule.RecipientType == NotificationRecipientTypes.All) if (notificationRule.RecipientType == NotificationRecipientTypes.All)
{ {
// Tum userlar userIds.AddRange(await queryUsers
var allUserIds = await queryUsers .Where(user => user.IsActive)
.Where(u => u.IsActive) .Select(user => user.Id)
.Select(a => a.Id) .ToListAsync());
.ToListAsync();
userIds.AddRange(allUserIds);
} }
else if (notificationRule.RecipientType == NotificationRecipientTypes.User) else if (notificationRule.RecipientType == NotificationRecipientTypes.User)
{ {
var user = await queryUsers.FirstOrDefaultAsync(a => a.UserName == notificationRule.RecipientId); var user = await queryUsers.FirstOrDefaultAsync(user => user.UserName == notificationRule.RecipientId);
if (user != null) if (user != null)
{ {
userIds.Add(user.Id); userIds.Add(user.Id);
@ -90,121 +153,50 @@ public class NotificationManager : INotificationManager
} }
else if (notificationRule.RecipientType == NotificationRecipientTypes.Role) else if (notificationRule.RecipientType == NotificationRecipientTypes.Role)
{ {
var role = await repositoryRole.FirstOrDefaultAsync(a => a.Name == notificationRule.RecipientId); var role = await repositoryRole.FirstOrDefaultAsync(role => role.Name == notificationRule.RecipientId);
if (role != null) if (role != null)
{ {
var roleUserIds = await queryUsers userIds.AddRange(await queryUsers
.Where(u => u.Roles.Any(r => r.RoleId == role.Id) && u.IsActive) .Where(user => user.Roles.Any(userRole => userRole.RoleId == role.Id) && user.IsActive)
.Select(a => a.Id) .Select(user => user.Id)
.ToListAsync(); .ToListAsync());
userIds.AddRange(roleUserIds);
} }
} }
else if (notificationRule.RecipientType == NotificationRecipientTypes.OrganizationUnit) else if (notificationRule.RecipientType == NotificationRecipientTypes.OrganizationUnit)
{ {
if (notificationRule.RecipientId.IsNullOrWhiteSpace())
{
return userIds;
}
var units = await repositoryOrganizationUnit.GetAllChildrenWithParentCodeAsync(notificationRule.RecipientId, null); var units = await repositoryOrganizationUnit.GetAllChildrenWithParentCodeAsync(notificationRule.RecipientId, null);
foreach (var unit in units) foreach (var unit in units)
{ {
var memberIds = await repositoryOrganizationUnit.GetMemberIdsAsync(unit.Id); userIds.AddRange(await repositoryOrganizationUnit.GetMemberIdsAsync(unit.Id));
userIds.AddRange(memberIds);
} }
} }
var notifications = new List<Notification>(); return [.. userIds.Distinct()];
//Eğer "Custom" seçildiyse girilen RecipientId dinamik olabilir. Telefon Numarası, Email vs.
if (notificationRule.RecipientType == NotificationRecipientTypes.Custom)
{
var notification = new Notification
{
NotificationTypeId = notificationRule.NotificationTypeId,
NotificationRuleId = notificationRule.Id,
NotificationChannel = notificationRule.Channel,
Identifier = notificationRule.RecipientId!,
UserId = currentUser.Id,
Message = NotificationMessage,
IsSent = false,
IsRead = false
};
notifications.Add(notification);
}
else
{
if (userIds.IsNullOrEmpty())
{
continue;
} }
userIds = [.. userIds.Distinct()]; private static Notification BuildNotification(NotificationRule notificationRule, string identifier, Guid? userId, string message) => new()
foreach (var userId in userIds)
{
var identifier = await notificationIdentifierProvider.GetIdentifier(userId, notificationRule.Channel);
if (identifier.IsNullOrEmpty())
{
continue;
}
var notification = new Notification
{ {
NotificationTypeId = notificationRule.NotificationTypeId, NotificationTypeId = notificationRule.NotificationTypeId,
NotificationRuleId = notificationRule.Id, NotificationRuleId = notificationRule.Id,
NotificationChannel = notificationRule.Channel, NotificationChannel = notificationRule.Channel,
Identifier = identifier, Identifier = identifier,
UserId = userId, UserId = userId,
Message = NotificationMessage, Message = message,
IsSent = false, IsSent = IsDeliveredOnCreate(notificationRule.Channel),
IsRead = false IsRead = false
}; };
notifications.Add(notification);
}
}
await repositoryNotification.InsertManyAsync(notifications);
}
}
/// <summary> /// <summary>
/// Belirtilen Ident /// Kanalin gonderimi kaydin kendisi mi. UiActivity'nin ayri bir gonderim adimi yoktur —
/// kayit dogrudan kullanicinin bildirim akisina yazilir, bu yuzden olusurken iletilmis
/// sayilir. Diger kanallar <c>NotificationWorker</c> ya da tarayici gosterimi ile isaretlenir;
/// isaretlenmeden okunamazlar.
/// </summary> /// </summary>
/// <param name="NotificationTypeId"></param> private static bool IsDeliveredOnCreate(string channel)
/// <param name="NotificationMessage"></param> => channel == NotificationChannels.UiActivity;
/// <param name="Identifier"></param>
/// <param name="UserId"></param>
/// <returns></returns>
public async Task SendNotificationAsync(
string NotificationType,
string NotificationChannel,
string NotificationMessage,
string Identifier,
Guid? UserId = null)
{
if (NotificationChannel == Enums.NotificationChannels.WhatsApp)
{
if (Identifier.StartsWith('5'))
{
Identifier = "+90" + Identifier;
} }
else if (Identifier.StartsWith("05"))
{
Identifier = "+9" + Identifier;
}
}
var notificationType = await repositoryType.FirstOrDefaultAsync(a => a.Name == NotificationType)
?? throw new Exception($"NotificationType '{NotificationType}' not found.");
await repositoryNotification.InsertAsync(new Notification
{
NotificationTypeId = notificationType.Id,
NotificationChannel = NotificationChannel,
Identifier = Identifier,
UserId = UserId,
Message = NotificationMessage,
IsSent = false,
IsRead = false
});
}
}

View file

@ -1,13 +0,0 @@
using System;
using System.Threading.Tasks;
namespace Sozsoft.Notifications.Domain.NotificationProviders;
public class MailNotification : INotification
{
public Task Send(string[] NotificationType, string NotificationMessage)
{
throw new NotImplementedException();
}
}

View file

@ -1,13 +0,0 @@
using System;
using System.Threading.Tasks;
namespace Sozsoft.Notifications.Domain.NotificationProviders;
public class SmsNotification : INotification
{
public Task Send(string[] NotificationType, string NotificationMessage)
{
throw new NotImplementedException();
}
}

View file

@ -20,6 +20,7 @@ public static class NotificationDbContextModelCreatingExtensions
b.ConfigureByConvention(); b.ConfigureByConvention();
b.Property(x => x.Name).IsRequired().HasMaxLength(128); b.Property(x => x.Name).IsRequired().HasMaxLength(128);
b.Property(x => x.Description).HasMaxLength(2048);
}); });
builder.Entity<NotificationRule>(b => builder.Entity<NotificationRule>(b =>

View file

@ -0,0 +1,47 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Volo.Abp.Security.Claims;
namespace Sozsoft.Platform.Notifications;
/// <summary>
/// Bildirimlerin aninda duyuruldugu baglanti. Istemci yalnizca dinler; okundu/gosterildi
/// isaretleri REST ucundan gider, boylece yetki ve dogrulama tek yoldan gecer.
/// <para>
/// Kullanici ve kiraci bilgisi baglantinin claim'lerinden okunur: hub cagrilari HTTP ardisik
/// duzeni disinda calistigi icin AsyncLocal tabanli <c>ICurrentUser</c> her zaman dolu olmaz.
/// </para>
/// </summary>
[Authorize]
public class NotificationHub : Hub
{
/// <summary>Istemcinin dinledigi olay adi; yeni bildirim uretildiginde tetiklenir.</summary>
public const string NotificationReceived = "NotificationReceived";
public override async Task OnConnectedAsync()
{
var (tenantId, userId) = GetConnectionIdentity();
if (userId.HasValue)
{
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroupName(tenantId, userId.Value));
}
await base.OnConnectedAsync();
}
private (Guid? TenantId, Guid? UserId) GetConnectionIdentity()
{
var user = Context.User;
return (ParseGuid(user?.FindFirst(AbpClaimTypes.TenantId)?.Value),
ParseGuid(user?.FindFirst(AbpClaimTypes.UserId)?.Value));
}
private static Guid? ParseGuid(string? value) =>
Guid.TryParse(value, out var parsed) ? parsed : null;
/// <summary>Bildirimler kullanici bazlidir; grup adi kiraci ile birlikte anahtarlanir.</summary>
public static string UserGroupName(Guid? tenantId, Guid userId) =>
$"notification:user:{tenantId?.ToString("N") ?? "host"}:{userId:N}";
}

View file

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Sozsoft.Notifications.Domain;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
namespace Sozsoft.Platform.Notifications;
/// <summary>
/// Duyuruyu SignalR uzerinden yapar. Yayin, kayit yazildiktan sonra "en iyi caba" ile denenir:
/// baglanti kurulamazsa bildirim kaybolmaz, istemci yedek yoklamada ayni kaydi gorur.
/// </summary>
[Dependency(ReplaceServices = true)]
public class NotificationRealtimeNotifier(
IHubContext<NotificationHub> hubContext,
ICurrentTenant currentTenant,
ILogger<NotificationRealtimeNotifier> logger)
: INotificationRealtimeNotifier, ITransientDependency
{
public async Task NotifyAsync(IReadOnlyCollection<NotificationDelivery> deliveries)
{
if (deliveries.Count == 0)
{
return;
}
var tenantId = currentTenant.Id;
// Ayni kullaniciya birden fazla kanaldan kayit dusebilir; istemci tek duyuruda kendi
// listesini tazeledigi icin kullanici basina bir mesaj yeterlidir.
foreach (var delivery in deliveries.GroupBy(delivery => delivery.UserId))
{
try
{
await hubContext.Clients
.Group(NotificationHub.UserGroupName(tenantId, delivery.Key))
.SendAsync(NotificationHub.NotificationReceived, new
{
Channels = delivery.Select(item => item.Channel).Distinct().ToArray()
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Realtime notification could not be published to {UserId}.", delivery.Key);
}
}
}
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -44,6 +45,7 @@ public class PlatformPermissionAppService : PermissionAppService
{ {
var baseResult = await base.GetAsync(providerName, providerKey); var baseResult = await base.GetAsync(providerName, providerKey);
var allDefinitions = await PermissionDefinitionManager.GetPermissionsAsync(); var allDefinitions = await PermissionDefinitionManager.GetPermissionsAsync();
var allGroups = await PermissionDefinitionManager.GetGroupsAsync();
// Yeni tipte result // Yeni tipte result
var result = new PlatformGetPermissionListResultDto var result = new PlatformGetPermissionListResultDto
@ -61,7 +63,7 @@ public class PlatformPermissionAppService : PermissionAppService
DisplayNameResource = group.DisplayNameResource DisplayNameResource = group.DisplayNameResource
}; };
foreach (var permission in group.Permissions) foreach (var permission in SortByOrder(group.Permissions, allDefinitions))
{ {
var definition = allDefinitions.FirstOrDefault(x => x.Name == permission.Name); var definition = allDefinitions.FirstOrDefault(x => x.Name == permission.Name);
var modules = definition?.Properties.GetOrDefault(PlatformConsts.Permissions.Modules)?.ToString(); var modules = definition?.Properties.GetOrDefault(PlatformConsts.Permissions.Modules)?.ToString();
@ -83,6 +85,55 @@ public class PlatformPermissionAppService : PermissionAppService
result.Groups.Add(newGroup); result.Groups.Add(newGroup);
} }
// Gruplarin sirasi AbpPermissionGroups.Order sutunundandir.
result.Groups = [.. result.Groups.OrderBy(group => GetOrder(allGroups, group.Name))];
return result; return result;
} }
/// <summary>
/// Yetkileri <c>AbpPermissions.Order</c> sutununa gore dizer; ABP kayitlari sirasiz okur.
/// Sira kardeşler arasindadir (her ust yetkinin altinda 1'den baslar), bu yuzden anahtar
/// kokten yapraga ust yetki zinciridir: ebeveynin anahtari cocugunkinin oneki oldugu icin
/// cocuk her zaman ebeveyninin altinda kalir.
/// </summary>
private static List<PermissionGrantInfoDto> SortByOrder(
List<PermissionGrantInfoDto> permissions,
IReadOnlyList<PermissionDefinition> definitions)
{
var byName = permissions.ToDictionary(permission => permission.Name!);
return [.. permissions.OrderBy(permission => BuildOrderKey(permission, byName, definitions), StringComparer.Ordinal)];
}
private static string BuildOrderKey(
PermissionGrantInfoDto permission,
Dictionary<string, PermissionGrantInfoDto> byName,
IReadOnlyList<PermissionDefinition> definitions)
{
var chain = new List<string>();
// Ust yetki listede yoksa (yetki suzulmusse) zincir orada biter.
for (var current = permission; current != null; current = current.ParentName == null ? null : byName.GetOrDefault(current.ParentName))
{
chain.Insert(0, GetOrder(definitions, current.Name).ToString("D6"));
}
// Ayirici rakamlardan kucuk bir karakterdir; boylece ebeveynin anahtari cocugunkinin onune duser.
return string.Join('/', chain);
}
private static int GetOrder(IReadOnlyList<PermissionDefinition> definitions, string? name)
=> GetOrder(definitions.FirstOrDefault(definition => definition.Name == name)?.Properties);
private static int GetOrder(IReadOnlyList<PermissionGroupDefinition> groups, string? name)
=> GetOrder(groups.FirstOrDefault(group => group.Name == name)?.Properties);
/// <summary>Deger kayitta yoksa (elle eklenmis yetki/grup) sona atilir.</summary>
private static int GetOrder(IDictionary<string, object?>? properties)
{
var value = properties?.GetOrDefault(PlatformConsts.Permissions.Order);
return value == null ? int.MaxValue : Convert.ToInt32(value);
}
} }

View file

@ -1926,6 +1926,90 @@
"en": "Jobs", "en": "Jobs",
"tr": "İşler" "tr": "İşler"
}, },
{
"resourceName": "Platform",
"key": "App.NotificationChannel.Sms",
"en": "SMS",
"tr": "SMS"
},
{
"resourceName": "Platform",
"key": "App.NotificationChannel.Mail",
"en": "E-mail",
"tr": "E-posta"
},
{
"resourceName": "Platform",
"key": "App.NotificationChannel.Rocket",
"en": "Rocket.Chat",
"tr": "Rocket.Chat"
},
{
"resourceName": "Platform",
"key": "App.NotificationChannel.Desktop",
"en": "Desktop",
"tr": "Masaüstü"
},
{
"resourceName": "Platform",
"key": "App.NotificationChannel.UiActivity",
"en": "Activity",
"tr": "Etkinlik"
},
{
"resourceName": "Platform",
"key": "App.NotificationChannel.UiToast",
"en": "In-app",
"tr": "Uygulama içi"
},
{
"resourceName": "Platform",
"key": "App.NotificationChannel.WhatsApp",
"en": "WhatsApp",
"tr": "WhatsApp"
},
{
"resourceName": "Platform",
"key": "App.NotificationChannel.Telegram",
"en": "Telegram",
"tr": "Telegram"
},
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.Reset",
"en": "Reset to default",
"tr": "Varsayılana döndür"
},
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.NoRule",
"en": "There is no notification rule addressing you yet.",
"tr": "Sizi hedefleyen bir bildirim kuralı henüz yok."
},
{
"resourceName": "Platform",
"key": "App.Notifications.Empty",
"en": "No notifications!",
"tr": "Bildirim yok!"
},
{
"resourceName": "Platform",
"key": "App.Notifications.EmptyHint",
"en": "Please try again later",
"tr": "Lütfen daha sonra tekrar deneyin"
},
{
"resourceName": "Platform",
"key": "App.Notifications.CreateTitle",
"en": "Send notification",
"tr": "Bildirim gönder"
},
{
"resourceName": "Platform",
"key": "App.Notifications.CreateHint",
"en": "The message is delivered to every recipient of this rule through its channel.",
"tr": "Mesaj bu kuralın tüm alıcılarına kendi kanalından iletilir."
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Notifications", "key": "App.Notifications",
@ -28065,14 +28149,14 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Notifications.NotificationTypes.Description", "key": "App.Notifications.NotificationTypes.Description",
"en": "Notification Types — the event definitions that produce notifications. Table: Sas_H_NotificationType · host scope · list screen.\nUsage: one row per event that should notify someone — NewOrder, OrderApproved, StockBelowCritical, NewUser, ApprovalPending. The type is the joint that binds the event to the rules: the rule screen selects a type and the code raising the notification passes the same name.\nFields:\n• Name → the type code referenced from everywhere. Write it stable and descriptive; it is used from SQL and from services\nHow it is triggered: NotificationManager.SendNotificationAsync(typeId, message) reads every rule of the type and produces one notification row per recipient. The trigger may be a screen's InsertAfterCommand/UpdateAfterCommand, a stored procedure, a Dynamic Service, or the 'Create' dialog on the Notification Rules screen.\nOrder of setup: 1) define the type here, 2) define the recipient and channel on Notification Rules, 3) make sure the NotificationWorker job is active.\nCaution: renaming a type breaks every caller referencing the old name — add a new type instead. A type with no rule produces no notification, it fails silently.", "en": "Notification Types — the event definitions that produce notifications. Table: Sas_H_NotificationType · host scope · list screen.\nUsage: one row per event that should notify someone — NewOrder, OrderApproved, StockBelowCritical, NewUser, ApprovalPending. The type is the joint that binds the event to the rules: the rule screen selects a type and the code raising the notification passes the same name.\nFields:\n• Name → the type code referenced from everywhere. Write it stable and descriptive; it is used from SQL and from services\n• Description → what the type is for: which event raises it and who is expected to act. It is shown as a hint on the user notification settings screen, so write it for the reader, at length if needed\nHow it is triggered: NotificationManager.SendNotificationAsync(typeId, message) reads every rule of the type and produces one notification row per recipient. The trigger may be a screen's InsertAfterCommand/UpdateAfterCommand, a stored procedure, a Dynamic Service, or the 'Create' dialog on the Notification Rules screen.\nOrder of setup: 1) define the type here, 2) define the recipient and channel on Notification Rules, 3) make sure the NotificationWorker job is active.\nCaution: renaming a type breaks every caller referencing the old name — add a new type instead. A type with no rule produces no notification, it fails silently.",
"tr": "Bildirim Türleri — bildirim üreten olay tanımları. Tablo: Sas_H_NotificationType · host kapsamlı · liste ekranı.\nKullanım: birine haber verilmesi gereken her olay için bir satır — YeniSiparis, SiparisOnaylandi, StokKritikAltinda, YeniKullanici, OnayBekliyor. Tür, olayı kurallara bağlayan eklem yeridir: kural ekranı bir tür seçer, bildirimi tetikleyen kod da aynı adı geçirir.\nAlanlar:\n• Name → her yerden referans verilen tür kodu. Kalıcı ve açıklayıcı yaz; SQL'den ve servislerden kullanılır\nNasıl tetiklenir: NotificationManager.SendNotificationAsync(typeId, mesaj) çağrısı türe ait tüm kuralları okur ve her alıcı için bir bildirim satırı üretir. Tetikleyici; ekranın InsertAfterCommand/UpdateAfterCommand tanımı, saklı yordam, Dynamic Service veya Bildirim Kuralları ekranındaki 'Create' diyaloğu olabilir.\nKurulum sırası: 1) türü burada tanımla, 2) Bildirim Kuralları ekranında alıcı ve kanalı tanımla, 3) NotificationWorker işinin aktif olduğundan emin ol.\nDikkat: türün adını değiştirmek eski adı referans veren tüm çağıranları bozar — bunun yerine yeni tür ekle. Kuralı olmayan tür bildirim üretmez, sessizce çalışır." "tr": "Bildirim Türleri — bildirim üreten olay tanımları. Tablo: Sas_H_NotificationType · host kapsamlı · liste ekranı.\nKullanım: birine haber verilmesi gereken her olay için bir satır — YeniSiparis, SiparisOnaylandi, StokKritikAltinda, YeniKullanici, OnayBekliyor. Tür, olayı kurallara bağlayan eklem yeridir: kural ekranı bir tür seçer, bildirimi tetikleyen kod da aynı adı geçirir.\nAlanlar:\n• Name → her yerden referans verilen tür kodu. Kalıcı ve açıklayıcı yaz; SQL'den ve servislerden kullanılır\n• Description → türün ne işe yaradığı: hangi olayda tetiklendiği ve kimin ne yapması beklendiği. Kullanıcının bildirim tercihleri ekranında ipucu olarak görünür, gerekirse uzun uzun yaz\nNasıl tetiklenir: NotificationManager.SendNotificationAsync(typeId, mesaj) çağrısı türe ait tüm kuralları okur ve her alıcı için bir bildirim satırı üretir. Tetikleyici; ekranın InsertAfterCommand/UpdateAfterCommand tanımı, saklı yordam, Dynamic Service veya Bildirim Kuralları ekranındaki 'Create' diyaloğu olabilir.\nKurulum sırası: 1) türü burada tanımla, 2) Bildirim Kuralları ekranında alıcı ve kanalı tanımla, 3) NotificationWorker işinin aktif olduğundan emin ol.\nDikkat: türün adını değiştirmek eski adı referans veren tüm çağıranları bozar — bunun yerine yeni tür ekle. Kuralı olmayan tür bildirim üretmez, sessizce çalışır."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Notifications.NotificationRules.Description", "key": "App.Notifications.NotificationRules.Description",
"en": "Notification Rules — who receives which event, through which channel. Table: Sas_H_NotificationRule · host scope · list screen. The 'Create' row command produces a notification by hand from the selected rule (CreateNotification dialog).\nFields:\n• NotificationTypeId → which event; the lookup comes from the Notification Types screen\n• RecipientType + RecipientId → who receives it (see below)\n• Channel → through which channel it is delivered (see below)\n• IsActive → is the rule on\n• IsFixed → the user cannot turn it off from the profile; mandatory notification\n• IsCustomized → the user has written their own preference for this type/channel\nRecipientType — who the rule addresses:\n• All → every active user\n• User → RecipientId is the user name (AbpUsers.UserName)\n• Role → RecipientId is the role name; everyone holding the role receives it\n• OrganizationUnit → RecipientId is the unit code; the unit and all of its children receive it\n• Custom → RecipientId is the address itself (phone, e-mail, chat id); no user lookup is made, so it can also notify someone outside the system\nChannel setup — for every channel except Custom the address is resolved from the user record:\n• Mail → AbpUsers.Email. Sender settings: Abp.Mailing.* and App.Sender.Mail.*\n• Sms → AbpUsers.PhoneNumber. Settings: App.Sender.Sms.PostaGuvercini.* (Url, Username, Password)\n• WhatsApp → AbpUsers.PhoneNumber, normalized to +90…. Settings: App.Sender.WhatsApp.Url, PhoneNumberId, Token, TemplateName. The message is sent through an approved template\n• Telegram → AbpUsers.TelegramChatId. Settings: App.Sender.Telegram.Url, BotToken, ParseMode. How to obtain the chat id: the user sends /start to the bot once, then the id is read from the result[].message.chat.id field of https://api.telegram.org/bot<BotToken>/getUpdates and written into the Telegram Chat Id field of Identity › Users (the user sees it read-only on their profile). Without /start the send fails with 'chat not found'\n• Rocket → the user's RocketUsername. Settings: App.Sender.Rocket.Url, UserId, Token\n• Desktop / UiActivity / UiToast → the user id itself; delivered inside the application (activity log, toast, browser notification)\nFlow: the rule produces a Notification row, then the NotificationWorker (Background Workers › Recurring Jobs, WorkerType = NotificationWorker, cron */5 by default) empties the queue channel by channel.\nCaution: if several rules exist for the same type and channel, precedence is IsFixed › IsCustomized › IsActive and only one of them runs. A rule whose channel address is empty on the user is skipped silently — when a message does not arrive, look at the Notifications screen first: an empty Identifier means a missing address, IsSent = false means a failing send.", "en": "Notification Rules — who receives which event, through which channel. Table: Sas_H_NotificationRule · host scope · list screen. The 'Create' row command produces a notification by hand from the selected rule (CreateNotification dialog).\nFields:\n• NotificationTypeId → which event; the lookup comes from the Notification Types screen\n• RecipientType + RecipientId → who receives it (see below)\n• Channel → through which channel it is delivered (see below)\n• IsActive → is the rule on\n• IsFixed → the user cannot turn it off from the profile; mandatory notification\n• IsCustomized → the user has written their own preference for this type/channel\nRecipientType — who the rule addresses:\n• All → every active user\n• User → RecipientId is the user name (AbpUsers.UserName)\n• Role → RecipientId is the role name; everyone holding the role receives it\n• OrganizationUnit → RecipientId is the unit code; the unit and all of its children receive it\n• Custom → RecipientId is the address itself (phone, e-mail, chat id); no user lookup is made, so it can also notify someone outside the system\nChannel setup — for every channel except Custom the address is resolved from the user record:\n• Mail → AbpUsers.Email. Sender settings: Abp.Mailing.* and App.Sender.Mail.*\n• Sms → AbpUsers.PhoneNumber. Settings: App.Sender.Sms.PostaGuvercini.* (Url, Username, Password)\n• WhatsApp → AbpUsers.PhoneNumber, normalized to +90…. Settings: App.Sender.WhatsApp.Url, PhoneNumberId, Token, TemplateName. The message is sent through an approved template\n• Telegram → AbpUsers.TelegramChatId. Settings: App.Sender.Telegram.Url, BotToken, ParseMode. How to obtain the chat id: the user sends /start to the bot once, then the id is read from the result[].message.chat.id field of https://api.telegram.org/bot<BotToken>/getUpdates and written into the Telegram Chat Id field of Identity › Users (the user sees it read-only on their profile). Without /start the send fails with 'chat not found'\n• Rocket → the user's RocketUsername. Settings: App.Sender.Rocket.Url, UserId, Token\n• Desktop / UiActivity / UiToast → the user id itself; delivered inside the application (activity log, toast, browser notification)\nFlow: the rule produces a Notification row, then the NotificationWorker (Background Workers › Recurring Jobs, WorkerType = NotificationWorker, cron */5 by default) empties the queue channel by channel.\nCaution: a user preference (IsCustomized with RecipientType = User) is not a rule: it only switches its own owner off for that type and channel, and an IsFixed rule ignores it. Rules themselves decide the recipients, and when several rules address the same user only one record is produced per channel. A rule whose channel address is empty on the user is skipped silently — when a message does not arrive, look at the Notifications screen first: an empty Identifier means a missing address, IsSent = false means a failing send.",
"tr": "Bildirim Kuralları — hangi olayı kim, hangi kanaldan alacak. Tablo: Sas_H_NotificationRule · host kapsamlı · liste ekranı. Satırdaki 'Create' komutu seçili kuraldan elle bildirim üretir (CreateNotification diyaloğu).\nAlanlar:\n• NotificationTypeId → hangi olay; lookup Bildirim Türleri ekranından gelir\n• RecipientType + RecipientId → kimin alacağı (aşağıya bak)\n• Channel → hangi kanaldan iletileceği (aşağıya bak)\n• IsActive → kural açık mı\n• IsFixed → kullanıcı profilinden kapatamaz; zorunlu bildirim\n• IsCustomized → kullanıcı bu tür/kanal için kendi tercihini yazmış\nRecipientType — kural kimi hedefler:\n• All → tüm aktif kullanıcılar\n• User → RecipientId kullanıcı adıdır (AbpUsers.UserName)\n• Role → RecipientId rol adıdır; role sahip herkes alır\n• OrganizationUnit → RecipientId birim kodudur; birim ve tüm alt birimleri alır\n• Custom → RecipientId doğrudan adresin kendisidir (telefon, e-posta, chat id); kullanıcı araması yapılmaz, böylece sistem dışındaki birine de haber verilebilir\nKanal kurulumu — Custom dışındaki her kanalda adres kullanıcı kaydından çözülür:\n• Mail → AbpUsers.Email. Gönderim ayarları: Abp.Mailing.* ve App.Sender.Mail.*\n• Sms → AbpUsers.PhoneNumber. Ayarlar: App.Sender.Sms.PostaGuvercini.* (Url, Username, Password)\n• WhatsApp → AbpUsers.PhoneNumber, +90… biçimine normalize edilir. Ayarlar: App.Sender.WhatsApp.Url, PhoneNumberId, Token, TemplateName. Mesaj onaylı şablon üzerinden gider\n• Telegram → AbpUsers.TelegramChatId. Ayarlar: App.Sender.Telegram.Url, BotToken, ParseMode. Chat id nasıl alınır: kullanıcı bota bir kez /start yazar, ardından https://api.telegram.org/bot<BotToken>/getUpdates çıktısındaki result[].message.chat.id değeri okunur ve Identity › Kullanıcılar ekranındaki Telegram Chat Id alanına yazılır (kullanıcı kendi profilinde salt okunur görür). /start yazılmadan gönderim 'chat not found' hatası verir\n• Rocket → kullanıcının RocketUsername alanı. Ayarlar: App.Sender.Rocket.Url, UserId, Token\n• Desktop / UiActivity / UiToast → kullanıcının kendi kimliği; uygulama içinde iletilir (aktivite kaydı, toast, tarayıcı bildirimi)\nAkış: kural bir Notification satırı üretir, ardından NotificationWorker (Background Workers › Recurring Jobs, WorkerType = NotificationWorker, varsayılan cron */5) kuyruğu kanal kanal boşaltır.\nDikkat: aynı tür ve kanal için birden fazla kural varsa öncelik IsFixed › IsCustomized › IsActive sırasındadır ve yalnızca biri çalışır. Kanal adresi kullanıcıda boş olan kural sessizce atlanır — mesaj gitmediğinde önce Bildirimler ekranına bak: Identifier boşsa adres eksiktir, IsSent = false ise gönderim hata alıyordur." "tr": "Bildirim Kuralları — hangi olayı kim, hangi kanaldan alacak. Tablo: Sas_H_NotificationRule · host kapsamlı · liste ekranı. Satırdaki 'Create' komutu seçili kuraldan elle bildirim üretir (CreateNotification diyaloğu).\nAlanlar:\n• NotificationTypeId → hangi olay; lookup Bildirim Türleri ekranından gelir\n• RecipientType + RecipientId → kimin alacağı (aşağıya bak)\n• Channel → hangi kanaldan iletileceği (aşağıya bak)\n• IsActive → kural açık mı\n• IsFixed → kullanıcı profilinden kapatamaz; zorunlu bildirim\n• IsCustomized → kullanıcı bu tür/kanal için kendi tercihini yazmış\nRecipientType — kural kimi hedefler:\n• All → tüm aktif kullanıcılar\n• User → RecipientId kullanıcı adıdır (AbpUsers.UserName)\n• Role → RecipientId rol adıdır; role sahip herkes alır\n• OrganizationUnit → RecipientId birim kodudur; birim ve tüm alt birimleri alır\n• Custom → RecipientId doğrudan adresin kendisidir (telefon, e-posta, chat id); kullanıcı araması yapılmaz, böylece sistem dışındaki birine de haber verilebilir\nKanal kurulumu — Custom dışındaki her kanalda adres kullanıcı kaydından çözülür:\n• Mail → AbpUsers.Email. Gönderim ayarları: Abp.Mailing.* ve App.Sender.Mail.*\n• Sms → AbpUsers.PhoneNumber. Ayarlar: App.Sender.Sms.PostaGuvercini.* (Url, Username, Password)\n• WhatsApp → AbpUsers.PhoneNumber, +90… biçimine normalize edilir. Ayarlar: App.Sender.WhatsApp.Url, PhoneNumberId, Token, TemplateName. Mesaj onaylı şablon üzerinden gider\n• Telegram → AbpUsers.TelegramChatId. Ayarlar: App.Sender.Telegram.Url, BotToken, ParseMode. Chat id nasıl alınır: kullanıcı bota bir kez /start yazar, ardından https://api.telegram.org/bot<BotToken>/getUpdates çıktısındaki result[].message.chat.id değeri okunur ve Identity › Kullanıcılar ekranındaki Telegram Chat Id alanına yazılır (kullanıcı kendi profilinde salt okunur görür). /start yazılmadan gönderim 'chat not found' hatası verir\n• Rocket → kullanıcının RocketUsername alanı. Ayarlar: App.Sender.Rocket.Url, UserId, Token\n• Desktop / UiActivity / UiToast → kullanıcının kendi kimliği; uygulama içinde iletilir (aktivite kaydı, toast, tarayıcı bildirimi)\nAkış: kural bir Notification satırı üretir, ardından NotificationWorker (Background Workers › Recurring Jobs, WorkerType = NotificationWorker, varsayılan cron */5) kuyruğu kanal kanal boşaltır.\nDikkat: kullanıcı tercihi (IsCustomized + RecipientType = User) bir kural değildir: yalnızca kendi sahibini o tür ve kanalda kapatır, IsFixed kuralda ise dikkate alınmaz. Alıcıları kurallar belirler; aynı kullanıcıyı birden çok kural hedeflerse kanal başına tek kayıt üretilir. Kanal adresi kullanıcıda boş olan kural sessizce atlanır — mesaj gitmediğinde önce Bildirimler ekranına bak: Identifier boşsa adres eksiktir, IsSent = false ise gönderim hata alıyordur."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",

View file

@ -179,6 +179,27 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
PermissionJson = DefaultFieldPermissionJson(AbpIdentity.PermissionGroups.Create, AbpIdentity.PermissionGroups.Default, AbpIdentity.PermissionGroups.Update, true, true, false), PermissionJson = DefaultFieldPermissionJson(AbpIdentity.PermissionGroups.Create, AbpIdentity.PermissionGroups.Default, AbpIdentity.PermissionGroups.Update, true, true, false),
PivotSettingsJson = DefaultPivotSettingsJson PivotSettingsJson = DefaultPivotSettingsJson
}, },
// Yetki ekranindaki grup sirasi; ABP kayitlari sirasiz okur.
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.Int32,
FieldName = "Order",
EditGroupOrderNo = 1,
EditOrderNo = 4,
EditorType2 = EditorTypes.dxNumberBox,
ColSpan = 1,
CaptionName = "App.Listform.ListformField.Order",
Width = 0,
ListOrderNo = 5,
Visible = true,
IsActive = true,
ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(AbpIdentity.PermissionGroups.Create, AbpIdentity.PermissionGroups.Default, AbpIdentity.PermissionGroups.Update, true, true, false),
PivotSettingsJson = DefaultPivotSettingsJson
},
}); });
#endregion #endregion
} }
@ -440,6 +461,27 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
ValueExpr = "key", ValueExpr = "key",
LookupQuery = LookupQueryValues.ModuleValues LookupQuery = LookupQueryValues.ModuleValues
}), }),
ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(AbpIdentity.Permissions.Create, AbpIdentity.Permissions.Default, AbpIdentity.Permissions.Update, true, true, false),
PivotSettingsJson = DefaultPivotSettingsJson
},
// Yetki ekranindaki gosterim sirasi; ABP kayitlari sirasiz okudugu icin sira buradan gelir.
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.Int32,
FieldName = "Order",
EditGroupOrderNo = 1,
EditOrderNo = 8,
EditorType2 = EditorTypes.dxNumberBox,
ColSpan = 1,
CaptionName = "App.Listform.ListformField.Order",
Width = 0,
ListOrderNo = 9,
Visible = true,
IsActive = true,
ValidationRuleJson = DefaultValidationRuleRequiredJson, ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson, ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(AbpIdentity.Permissions.Create, AbpIdentity.Permissions.Default, AbpIdentity.Permissions.Update, true, true, false), PermissionJson = DefaultFieldPermissionJson(AbpIdentity.Permissions.Create, AbpIdentity.Permissions.Default, AbpIdentity.Permissions.Update, true, true, false),

View file

@ -5218,7 +5218,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
DeleteFieldsDefaultValueJson = DefaultDeleteFieldsDefaultValueJson(), DeleteFieldsDefaultValueJson = DefaultDeleteFieldsDefaultValueJson(),
InsertFieldsDefaultValueJson = DefaultInsertFieldsDefaultValueJson(), InsertFieldsDefaultValueJson = DefaultInsertFieldsDefaultValueJson(),
PagerOptionJson = DefaultPagerOptionJson, PagerOptionJson = DefaultPagerOptionJson,
EditingOptionJson = DefaultEditingOptionJson(listFormName, 400, 200, true, true, true, true, false), EditingOptionJson = DefaultEditingOptionJson(listFormName, 600, 420, true, true, true, true, false),
EditingFormJson = JsonSerializer.Serialize(new List<EditingFormDto> EditingFormJson = JsonSerializer.Serialize(new List<EditingFormDto>
{ {
new() { new() {
@ -5262,6 +5262,27 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
ValidationRuleJson = DefaultValidationRuleRequiredJson, ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson, ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name), PermissionJson = DefaultFieldPermissionJson(listForm.Name),
},
new()
{
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Description",
EditGroupOrderNo = 1,
EditOrderNo = 2,
EditorType2 = EditorTypes.dxTextArea,
EditorOptions = EditorOptions.Multiline(200),
ColSpan = 1,
CaptionName = "App.Listform.ListformField.Description",
Width = 0,
ListOrderNo = 3,
Visible = true,
IsActive = true,
AllowSearch = true,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
} }
]); ]);
#endregion #endregion

View file

@ -31,6 +31,12 @@ public static class PlatformConsts
public static class Permissions public static class Permissions
{ {
public const string Modules = "Modules"; public const string Modules = "Modules";
/// <summary>
/// Yetki agacinin gosterim sirasi. ABP kayitlari sirasiz okur; sira kayitta tasinmazsa
/// yetki ekrani her acilista baska bir dizilim gosterir.
/// </summary>
public const string Order = "Order";
} }
public static class Tenants public static class Tenants

View file

@ -2,7 +2,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Sozsoft.Notifications.Entities; using Sozsoft.Notifications.Entities;
using Sozsoft.Notifications.Enums; using Sozsoft.Notifications.Enums;
@ -14,17 +13,19 @@ using Sozsoft.Sender.Telegram;
using Sozsoft.Sender.WhatsApp; using Sozsoft.Sender.WhatsApp;
using Volo.Abp.BackgroundWorkers; using Volo.Abp.BackgroundWorkers;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
using Volo.Abp.Identity;
using Volo.Abp.Settings; using Volo.Abp.Settings;
using Volo.Abp.Uow; using Volo.Abp.Uow;
namespace Sozsoft.Platform.BackgroundWorkers; namespace Sozsoft.Platform.BackgroundWorkers;
/// <summary>
/// Bildirim kuyrugunu kanal kanal bosaltir: gonderilmemis her kaydi kendi saglayicisina verir ve
/// yalnizca basarili gonderimde <c>IsSent</c> isaretler. Okundu bilgisi kullanicinin aksiyonudur,
/// gonderim onu belirlemez. Tetikleme zamanlanmis istir (Recurring Jobs, <c>WorkerType = NotificationWorker</c>).
/// </summary>
public class NotificationWorker : BackgroundWorkerBase public class NotificationWorker : BackgroundWorkerBase
{ {
protected IRepository<Notification> Repository { get; } protected IRepository<Notification> Repository { get; }
protected IIdentityUserRepository UserRepository { get; }
protected IdentityUserManager IdentityUserManager { get; }
protected ISozsoftRocketSender ErpRocketSender { get; } protected ISozsoftRocketSender ErpRocketSender { get; }
protected ISozsoftEmailSender ErpEmailSender { get; } protected ISozsoftEmailSender ErpEmailSender { get; }
protected ISozsoftSmsSender ErpSmsSender { get; } protected ISozsoftSmsSender ErpSmsSender { get; }
@ -34,8 +35,6 @@ public class NotificationWorker : BackgroundWorkerBase
public NotificationWorker( public NotificationWorker(
IRepository<Notification> repository, IRepository<Notification> repository,
IIdentityUserRepository userRepository,
IdentityUserManager identityUserManager,
ISozsoftSmsSender erpSmsSender, ISozsoftSmsSender erpSmsSender,
ISozsoftRocketSender erpRocketSender, ISozsoftRocketSender erpRocketSender,
ISozsoftEmailSender erpEmailSender, ISozsoftEmailSender erpEmailSender,
@ -45,8 +44,6 @@ public class NotificationWorker : BackgroundWorkerBase
{ {
Repository = repository; Repository = repository;
ErpSmsSender = erpSmsSender; ErpSmsSender = erpSmsSender;
UserRepository = userRepository;
IdentityUserManager = identityUserManager;
ErpRocketSender = erpRocketSender; ErpRocketSender = erpRocketSender;
ErpEmailSender = erpEmailSender; ErpEmailSender = erpEmailSender;
ErpWhatsAppSender = erpWhatsAppSender; ErpWhatsAppSender = erpWhatsAppSender;
@ -56,115 +53,79 @@ public class NotificationWorker : BackgroundWorkerBase
public override async Task StartAsync(CancellationToken cancellationToken = default) public override async Task StartAsync(CancellationToken cancellationToken = default)
{ {
using var scope = ServiceProvider.CreateScope();
var uowManager = LazyServiceProvider.LazyGetRequiredService<IUnitOfWorkManager>(); var uowManager = LazyServiceProvider.LazyGetRequiredService<IUnitOfWorkManager>();
using var uow = uowManager.Begin(); using var uow = uowManager.Begin();
var sms = await Repository.GetListAsync(a => a.NotificationChannel == NotificationChannels.Sms && !a.IsSent, cancellationToken: cancellationToken); await SendChannelAsync(NotificationChannels.Sms, async notification => (await ErpSmsSender.SendAsync(notification.Identifier, notification.Message)).Success, cancellationToken);
foreach (var notification in sms) await SendChannelAsync(NotificationChannels.Rocket, async notification => (await ErpRocketSender.SendAsync(notification.Identifier, notification.Message)).Success, cancellationToken);
{ await SendChannelAsync(NotificationChannels.WhatsApp, async notification => (await ErpWhatsAppSender.SendAsync(notification.Identifier, notification.Message)).Success, cancellationToken);
try await SendChannelAsync(NotificationChannels.Telegram, async notification => (await ErpTelegramSender.SendAsync(notification.Identifier, notification.Message)).Success, cancellationToken);
{ await SendMailAsync(cancellationToken);
var result = await ErpSmsSender.SendAsync(notification.Identifier, notification.Message);
if (result.Success)
{
notification.IsSent = true;
notification.IsRead = true;
await Repository.UpdateAsync(notification, autoSave: true, cancellationToken: cancellationToken);
}
}
catch (Exception ex)
{
Logger.LogException(ex);
}
}
var rocket = await Repository.GetListAsync(a => a.NotificationChannel == NotificationChannels.Rocket && !a.IsSent, cancellationToken: cancellationToken);
foreach (var notification in rocket)
{
try
{
var result = await ErpRocketSender.SendAsync(notification.Identifier, notification.Message);
if (result.Success)
{
notification.IsSent = true;
notification.IsRead = true;
await Repository.UpdateAsync(notification, autoSave: true, cancellationToken: cancellationToken);
}
}
catch (Exception ex)
{
Logger.LogException(ex);
}
}
var mail = await Repository.GetListAsync(a => a.NotificationChannel == NotificationChannels.Mail && !a.IsSent, cancellationToken: cancellationToken);
var senderName = await SettingProvider.GetOrNullAsync(SeedConsts.AbpSettings.Mailing.Default.DefaultFromDisplayName);
var senderEmail = await SettingProvider.GetOrNullAsync(SeedConsts.AbpSettings.Mailing.Default.DefaultFromAddress);
foreach (var notification in mail)
{
try
{
var result = await ErpEmailSender.SendEmailAsync(
notification.Identifier,
new KeyValuePair<string, string>(senderName, senderEmail),
new { },
notification.Message,
$"Platform Bildirim: {notification.NotificationType}");
if (result.Success)
{
notification.IsSent = true;
notification.IsRead = true;
await Repository.UpdateAsync(notification, autoSave: true, cancellationToken: cancellationToken);
}
}
catch (Exception ex)
{
Logger.LogException(ex);
}
}
var whatsapp = await Repository.GetListAsync(a => a.NotificationChannel == NotificationChannels.WhatsApp && !a.IsSent, cancellationToken: cancellationToken);
foreach (var notification in whatsapp)
{
try
{
var result = await ErpWhatsAppSender.SendAsync(notification.Identifier, notification.Message);
if (result.Success)
{
notification.IsSent = true;
notification.IsRead = true;
await Repository.UpdateAsync(notification, autoSave: true, cancellationToken: cancellationToken);
}
}
catch (Exception ex)
{
Logger.LogException(ex);
}
}
var telegram = await Repository.GetListAsync(a => a.NotificationChannel == NotificationChannels.Telegram && !a.IsSent, cancellationToken: cancellationToken);
foreach (var notification in telegram)
{
try
{
var result = await ErpTelegramSender.SendAsync(notification.Identifier, notification.Message);
if (result.Success)
{
notification.IsSent = true;
notification.IsRead = true;
await Repository.UpdateAsync(notification, autoSave: true, cancellationToken: cancellationToken);
}
}
catch (Exception ex)
{
Logger.LogException(ex);
}
}
await uow.SaveChangesAsync(cancellationToken); await uow.SaveChangesAsync(cancellationToken);
} }
/// <summary>
/// Bir kanalin bekleyen kayitlarini gonderir. Bir kaydin hatasi digerlerini durdurmaz; hata
/// loglanir ve kayit gonderilmemis kalir, boylece bir sonraki calismada yeniden denenir.
/// </summary>
private async Task SendChannelAsync(
string channel,
Func<Notification, Task<bool>> send,
CancellationToken cancellationToken,
bool includeDetails = false)
{
var pending = await Repository.GetListAsync(
notification => notification.NotificationChannel == channel && !notification.IsSent,
includeDetails,
cancellationToken);
foreach (var notification in pending)
{
try
{
if (!await send(notification))
{
continue;
} }
notification.IsSent = true;
await Repository.UpdateAsync(notification, autoSave: true, cancellationToken: cancellationToken);
}
catch (Exception ex)
{
Logger.LogException(ex);
}
}
}
/// <summary>
/// Mail kanali gonderen kimligini de tasir; bu yuzden ayri durur. Gonderen ayari eksikse
/// kuyruk denenmez — her kayit icin ayni hatayi almak yerine tek satir uyari yazilir.
/// </summary>
private async Task SendMailAsync(CancellationToken cancellationToken)
{
var senderName = await SettingProvider.GetOrNullAsync(SeedConsts.AbpSettings.Mailing.Default.DefaultFromDisplayName);
var senderEmail = await SettingProvider.GetOrNullAsync(SeedConsts.AbpSettings.Mailing.Default.DefaultFromAddress);
if (senderEmail.IsNullOrWhiteSpace())
{
Logger.LogWarning(
"Mail notifications are not sent: the {Setting} setting is empty.",
SeedConsts.AbpSettings.Mailing.Default.DefaultFromAddress);
return;
}
// Konu satirinda bildirim tipinin adi gecer; navigasyon yuklenmezse konu bos kalirdi.
await SendChannelAsync(
NotificationChannels.Mail,
async notification => (await ErpEmailSender.SendEmailAsync(
notification.Identifier,
new KeyValuePair<string, string>(senderName ?? senderEmail!, senderEmail!),
new { },
notification.Message,
$"Platform Bildirim: {notification.NotificationType?.Name}")).Success,
cancellationToken,
includeDetails: true);
}
}

View file

@ -487,6 +487,24 @@ public static class PlatformEfCoreEntityExtensionMappings
propertyBuilder.HasMaxLength(256).HasDefaultValue(null); propertyBuilder.HasMaxLength(256).HasDefaultValue(null);
} }
); );
ObjectExtensionManager.Instance
.MapEfCoreProperty<PermissionDefinitionRecord, int>(
PlatformConsts.Permissions.Order,
(entityBuilder, propertyBuilder) =>
{
propertyBuilder.HasDefaultValue(0);
}
);
ObjectExtensionManager.Instance
.MapEfCoreProperty<PermissionGroupDefinitionRecord, int>(
PlatformConsts.Permissions.Order,
(entityBuilder, propertyBuilder) =>
{
propertyBuilder.HasDefaultValue(0);
}
);
}); });
} }
} }

View file

@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
namespace Sozsoft.Platform.Migrations namespace Sozsoft.Platform.Migrations
{ {
[DbContext(typeof(PlatformDbContext))] [DbContext(typeof(PlatformDbContext))]
[Migration("20260918122519_Initial")] [Migration("20260920201729_Initial")]
partial class Initial partial class Initial
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -680,6 +680,10 @@ namespace Sozsoft.Platform.Migrations
.HasColumnType("datetime2") .HasColumnType("datetime2")
.HasColumnName("DeletionTime"); .HasColumnName("DeletionTime");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("bit") .HasColumnType("bit")
@ -8399,6 +8403,11 @@ namespace Sozsoft.Platform.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("nvarchar(128)"); .HasColumnType("nvarchar(128)");
b.Property<int>("Order")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<string>("ParentName") b.Property<string>("ParentName")
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("nvarchar(128)"); .HasColumnType("nvarchar(128)");
@ -8475,6 +8484,11 @@ namespace Sozsoft.Platform.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("nvarchar(128)"); .HasColumnType("nvarchar(128)");
b.Property<int>("Order")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Name") b.HasIndex("Name")

View file

@ -221,7 +221,8 @@ namespace Sozsoft.Platform.Migrations
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false), Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false), Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
DisplayName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false), DisplayName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ExtraProperties = table.Column<string>(type: "nvarchar(max)", nullable: true) ExtraProperties = table.Column<string>(type: "nvarchar(max)", nullable: true),
Order = table.Column<int>(type: "int", nullable: false, defaultValue: 0)
}, },
constraints: table => constraints: table =>
{ {
@ -242,7 +243,8 @@ namespace Sozsoft.Platform.Migrations
Providers = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true), Providers = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
StateCheckers = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true), StateCheckers = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ExtraProperties = table.Column<string>(type: "nvarchar(max)", nullable: true), ExtraProperties = table.Column<string>(type: "nvarchar(max)", nullable: true),
Modules = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true) Modules = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Order = table.Column<int>(type: "int", nullable: false, defaultValue: 0)
}, },
constraints: table => constraints: table =>
{ {
@ -1534,6 +1536,7 @@ namespace Sozsoft.Platform.Migrations
{ {
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false), Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false), Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Description = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false), CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true), CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true), LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),

View file

@ -677,6 +677,10 @@ namespace Sozsoft.Platform.Migrations
.HasColumnType("datetime2") .HasColumnType("datetime2")
.HasColumnName("DeletionTime"); .HasColumnName("DeletionTime");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("bit") .HasColumnType("bit")
@ -8396,6 +8400,11 @@ namespace Sozsoft.Platform.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("nvarchar(128)"); .HasColumnType("nvarchar(128)");
b.Property<int>("Order")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<string>("ParentName") b.Property<string>("ParentName")
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("nvarchar(128)"); .HasColumnType("nvarchar(128)");
@ -8472,6 +8481,11 @@ namespace Sozsoft.Platform.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("nvarchar(128)"); .HasColumnType("nvarchar(128)");
b.Property<int>("Order")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Name") b.HasIndex("Name")

View file

@ -29,6 +29,7 @@ using Sozsoft.Mcp;
using Sozsoft.Languages; using Sozsoft.Languages;
using Sozsoft.MailQueue; using Sozsoft.MailQueue;
using Sozsoft.Notifications.Application; using Sozsoft.Notifications.Application;
using Sozsoft.Platform.Notifications;
using Sozsoft.Platform.DbStartup; using Sozsoft.Platform.DbStartup;
using Sozsoft.Platform.DynamicServices; using Sozsoft.Platform.DynamicServices;
using Sozsoft.Platform.EntityFrameworkCore; using Sozsoft.Platform.EntityFrameworkCore;
@ -508,7 +509,10 @@ public class PlatformHttpApiHostModule : AbpModule
app.Use(async (context, next) => app.Use(async (context, next) =>
{ {
if (context.Request.Path.StartsWithSegments("/messengerhub") && // SignalR tarayici istemcisi WebSocket disindaki tasimalarda Authorization basligi
// gonderemez; token sorgu dizesinden gelir ve baslik olarak yazilir.
if ((context.Request.Path.StartsWithSegments("/messengerhub") ||
context.Request.Path.StartsWithSegments("/notificationhub")) &&
context.Request.Query.TryGetValue("access_token", out var accessToken) && context.Request.Query.TryGetValue("access_token", out var accessToken) &&
!string.IsNullOrWhiteSpace(accessToken.ToString())) !string.IsNullOrWhiteSpace(accessToken.ToString()))
{ {
@ -561,6 +565,7 @@ public class PlatformHttpApiHostModule : AbpModule
app.UseConfiguredEndpoints(endpoints => app.UseConfiguredEndpoints(endpoints =>
{ {
endpoints.MapHub<MessengerHub>("/messengerhub"); endpoints.MapHub<MessengerHub>("/messengerhub");
endpoints.MapHub<NotificationHub>("/notificationhub");
// MCP (Model Context Protocol) ucu. Kimlik dogrulama, kiraci cozumu ve unit of work // MCP (Model Context Protocol) ucu. Kimlik dogrulama, kiraci cozumu ve unit of work
// yukaridaki ardisik duzenden gelir; araclar kendi [Authorize] yetkilerini ister. // yukaridaki ardisik duzenden gelir; araclar kendi [Authorize] yetkilerini ister.

View file

@ -181,7 +181,10 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
`IListFormArtifact` / `IListFormFieldArtifact` sözleşmeleri üzerinden yapılır. `IListFormArtifact` / `IListFormFieldArtifact` sözleşmeleri üzerinden yapılır.
Ayrıntı: `dotnet.instructions.md` §5. Ayrıntı: `dotnet.instructions.md` §5.
- **Yeni bir yetki** → `PlatformConsts.AppCodes` + `data/Abp.Identity.Permissions.json` + (UI'da kullanılacaksa) - **Yeni bir yetki** → `PlatformConsts.AppCodes` + `data/Abp.Identity.Permissions.json` + (UI'da kullanılacaksa)
`permission.constant.ts` + `LanguagesData.json`. `permission.constant.ts` + `LanguagesData.json`. Yetki ekranındaki sıra `AbpPermissions.Order`
(grup için `AbpPermissionGroups.Order`) sütunundan gelir ve seed satırındaki `Order` alanıyla
yazılır; sıra kardeşler arasındadır — her üst yetkinin altında 1'den başlar, yeni satırı menüdeki
yerine denk gelen numarayla ekle.
- **`/mcp` ucuna yeni bir araç** → üç yer birlikte değişir: `McpToolNames` (ad kayıt defteri), - **`/mcp` ucuna yeni bir araç** → üç yer birlikte değişir: `McpToolNames` (ad kayıt defteri),
ilgili `Mcp*Tools` sınıfı (`Sozsoft.Mcp.HttpApi`) ve arkasındaki AppService sözleşmesi ilgili `Mcp*Tools` sınıfı (`Sozsoft.Mcp.HttpApi`) ve arkasındaki AppService sözleşmesi
(`Sozsoft.Mcp.Application.Contracts`). Arac adı `McpToolNames.All` içinde yoksa araç listesi (`Sozsoft.Mcp.Application.Contracts`). Arac adı `McpToolNames.All` içinde yoksa araç listesi
@ -213,6 +216,15 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
karşılık gelen yetkiyle eklenir. `create_table` bu yüzden `.Develop`'a ek olarak `App.SqlQueryManager` karşılık gelen yetkiyle eklenir. `create_table` bu yüzden `.Develop`'a ek olarak `App.SqlQueryManager`
ister; tablo scriptini tasarımcının `generateCreateTableSql` çıktısıyla aynı biçimde üretir ister; tablo scriptini tasarımcının `generateCreateTableSql` çıktısıyla aynı biçimde üretir
(`McpTableCreator`) — tasarımcının şablon kolonları ya da script biçimi değişirse o da değişir. (`McpTableCreator`) — tasarımcının şablon kolonları ya da script biçimi değişirse o da değişir.
- **Bildirim bayrakları** → sıra değişmez: `IsSent` (iletildi) olmadan `IsRead` yazılmaz, `IsRead`
ile `ReadTime` birlikte yazılır. `UiActivity` kayıt oluşurken iletilmiş sayılır, `UiToast`/`Desktop`
tarayıcıda gösterildiğinde (`NotificationChannels.BrowserChannels`; `sent-many` ucu yalnızca bu
kümeyi işaretler), kalan kanalları `NotificationWorker` işaretler — gönderim okuma değildir.
Kullanıcı tercihi (`IsCustomized` + `RecipientType = User`) kural değildir, yalnızca sahibini
kapatır; alıcıyı kurallar belirler. Yeni bildirim üretildiği anda `/notificationhub` ile
duyurulur (`INotificationRealtimeNotifier`; sözleşme bildirim modülünün Domain'inde, SignalR
gerçeklemesi barındıran uygulamada — modül katmanı ASP.NET Core'a bağlanmaz) — duyuru en iyi çabadır, istemcideki yedek yoklama kaldırılmaz.
Ayrıntı: `README.md` §13.3–§13.5.
- **Yeni bir dil anahtarı** → `LanguagesData.json`; eklemeden önce anahtarın zaten var olup - **Yeni bir dil anahtarı** → `LanguagesData.json`; eklemeden önce anahtarın zaten var olup
olmadığını kontrol et. olmadığını kontrol et.
- **Custom component sınıfları** → bileşen kodu veritabanında yaşar, Tailwind derlemede yalnızca - **Custom component sınıfları** → bileşen kodu veritabanında yaşar, Tailwind derlemede yalnızca

View file

@ -111,6 +111,20 @@ server {
proxy_send_timeout 3600; proxy_send_timeout 3600;
} }
location /notificationhub {
proxy_pass http://127.0.0.1:8080/notificationhub;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header __tenant $http___tenant;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
}
# MCP (Model Context Protocol) ucu. Streamable HTTP yanitlari SSE akisidir: location / # MCP (Model Context Protocol) ucu. Streamable HTTP yanitlari SSE akisidir: location /
# altindaki tamponlama akisi bekletip istemcinin zaman asimina dusmesine yol acar, # altindaki tamponlama akisi bekletip istemcinin zaman asimina dusmesine yol acar,
# bu yuzden ayri bir blokta tamponlama kapatilir. # bu yuzden ayri bir blokta tamponlama kapatilir.

View file

@ -6,11 +6,13 @@
"Rows": [ "Rows": [
{ {
"Name": "App.Saas", "Name": "App.Saas",
"DisplayName": "App.Saas" "DisplayName": "App.Saas",
"Order": 1
}, },
{ {
"Name": "App.Administration", "Name": "App.Administration",
"DisplayName": "App.Administration" "DisplayName": "App.Administration",
"Order": 2
} }
] ]
} }

File diff suppressed because it is too large Load diff

View file

@ -6,15 +6,18 @@
"Rows": [ "Rows": [
{ {
"Id": "095dcdb5-7c4a-5668-81bb-abc7b01b2158", "Id": "095dcdb5-7c4a-5668-81bb-abc7b01b2158",
"Name": "YeniSiparis" "Name": "YeniSiparis",
"Description": "Yeni bir sipariş oluşturulduğunda tetiklenir. Siparişi karşılayacak ekibin haberdar olması içindir; mesaj metni sipariş numarası ve müşteri adı gibi bilgileri taşır. Tetikleyici genellikle sipariş ekranının kayıt sonrası komutu ya da sipariş tablosunu yazan saklı yordamdır."
}, },
{ {
"Id": "c58237f7-edd6-55a3-9bc9-aab82f63ca3e", "Id": "c58237f7-edd6-55a3-9bc9-aab82f63ca3e",
"Name": "SiparisPasla" "Name": "SiparisPasla",
"Description": "Bir sipariş başka bir kullanıcıya ya da ekibe devredildiğinde tetiklenir. Devralının işi sahiplendiğini bilmesi içindir; mesajda kimin kime devrettiği ve sipariş bilgisi geçer."
}, },
{ {
"Id": "78a4b82b-de80-5826-bf9b-e49a2436cd43", "Id": "78a4b82b-de80-5826-bf9b-e49a2436cd43",
"Name": "YeniKullanici" "Name": "YeniKullanici",
"Description": "Sisteme yeni bir kullanıcı eklendiğinde tetiklenir. Yönetici ekibin yetki ve rol atamasını yapması için haberdar olmasını sağlar."
} }
] ]
} }

View file

@ -18,6 +18,7 @@ import {
updateReadMany, updateReadMany,
updateSentMany, updateSentMany,
} from '@/services/notification.service' } from '@/services/notification.service'
import { notificationSignalR } from '@/services/notification.signalr'
import { useStoreState } from '@/store' import { useStoreState } from '@/store'
import withHeaderItem from '@/utils/hoc/withHeaderItem' import withHeaderItem from '@/utils/hoc/withHeaderItem'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
@ -53,7 +54,8 @@ type NotificationListItem = {
} }
const notificationHeight = 'h-[26rem]' const notificationHeight = 'h-[26rem]'
const notificationInterval = 120000 // 2 minutes /** SignalR duyurusu kaybolursa devreye giren yedek yoklama aralığı. */
const notificationInterval = 300000 // 5 minutes
const foregroundPollThrottle = 30000 // 30 seconds const foregroundPollThrottle = 30000 // 30 seconds
/** /**
@ -146,7 +148,7 @@ const NotificationToggle = ({
) )
} }
const _Notification = ({ className }: { className?: string }) => { const NotificationDropdown = ({ className }: { className?: string }) => {
const { translate } = useLocalization() const { translate } = useLocalization()
const [notificationList, setNotificationList] = useState<NotificationListItem[]>([]) const [notificationList, setNotificationList] = useState<NotificationListItem[]>([])
@ -165,24 +167,33 @@ const _Notification = ({ className }: { className?: string }) => {
const direction = useStoreState((state) => state.theme.direction) const direction = useStoreState((state) => state.theme.direction)
/** /**
* Badge ve dropdown içeriği tek istekten beslenir; böylece rozetteki sayı ile * Dropdown listesi son `inboxSize` kaydı gösterir; rozetteki sayı ise sunucudan okunan
* listedeki okunmamış kayıt sayısı her zaman aynı kalır. * toplam okunmamış kayıt sayısıdır. Sayıyı sayfadan hesaplamak 50'den fazla okunmamış
* bildirimde rozeti sessizce tavanlıyordu. Listedeki satırlar aynı mesajın kanal
* kopyalarını birleştirdiği için satır sayısı ile rozet birebir aynı olmak zorunda değildir.
*/ */
const getInbox = useCallback(async () => { const getInbox = useCallback(async () => {
const resp = await getList({ const [listResponse, unreadResponse] = await Promise.all([
getList({
channels: inboxChannels, channels: inboxChannels,
// Kanallar arasında UiToast olduğu için servis, geçmişi son 24 saatle // Kanallar arasında UiToast olduğu için servis, geçmişi son 24 saatle
// sınırlayan push filtresini uygulamasın diye liste modunda çağrılır. // sınırlayan push filtresini uygulamasın diye liste modunda çağrılır.
isListRequest: true, isListRequest: true,
sorting: 'CreationTime desc', sorting: 'CreationTime desc',
maxResultCount: inboxSize, maxResultCount: inboxSize,
}) }),
getList({
channels: inboxChannels,
isListRequest: true,
isRead: false,
maxResultCount: 1,
}),
])
const items = resp.data.items ?? [] const items = listResponse.data.items ?? []
const groups = groupNotifications(items)
setNotificationList(groups) setNotificationList(groupNotifications(items))
setUnreadNotificationCount(groups.filter((a) => !a.readed).length) setUnreadNotificationCount(unreadResponse.data.totalCount ?? 0)
listLoaded.current = true listLoaded.current = true
return items return items
@ -209,9 +220,14 @@ const _Notification = ({ className }: { className?: string }) => {
if (pending.length === 0) { if (pending.length === 0) {
return return
} }
pending.forEach((a) => pushedIds.current.add(a.id))
// Yalnızca gerçekten gösterilen kayıt "gönderildi" sayılır. Patlamayı sınırlamak için
// kesilen kuyruk işaretlenseydi o bildirimler hiç görünmeden kaybolurdu; işaretlenmeyen
// kayıtlar bir sonraki yoklamada yeniden denenir.
const shown: string[] = []
for (const notification of pending.slice(0, maxPushBurst)) { for (const notification of pending.slice(0, maxPushBurst)) {
try {
if (notification.notificationChannel === NotificationChannels.UiToast) { if (notification.notificationChannel === NotificationChannels.UiToast) {
toast.push( toast.push(
<Notify type="success" duration={0} closable={true}> <Notify type="success" duration={0} closable={true}>
@ -219,6 +235,7 @@ const _Notification = ({ className }: { className?: string }) => {
</Notify>, </Notify>,
{ placement: 'bottom-end' }, { placement: 'bottom-end' },
) )
shown.push(notification.id)
continue continue
} }
@ -227,6 +244,8 @@ const _Notification = ({ className }: { className?: string }) => {
body: notification.message, body: notification.message,
dir: 'ltr', dir: 'ltr',
requireInteraction: true, requireInteraction: true,
// Service worker tıklamada bu adrese götürür; yoksa etkinlik kaydı ekranı açılır.
data: { url: ROUTES_ENUM.protected.admin.activityLog },
} }
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
@ -235,23 +254,41 @@ const _Notification = ({ className }: { className?: string }) => {
} else { } else {
new window.Notification(title, options) new window.Notification(title, options)
} }
shown.push(notification.id)
} catch {
// Gösterim başarısızsa (izin kalkmış, service worker hazır değil) kayıt
// işaretlenmez; sonraki yoklamada tekrar denenir.
} }
}
if (shown.length === 0) {
return
}
shown.forEach((id) => pushedIds.current.add(id))
// Yalnızca "gösterildi" işaretlenir; okundu bilgisi kullanıcının kendi // Yalnızca "gösterildi" işaretlenir; okundu bilgisi kullanıcının kendi
// aksiyonuna bırakılır, aksi hâlde mesaj badge'e hiç düşmezdi. // aksiyonuna bırakılır, aksi hâlde mesaj badge'e hiç düşmezdi.
await updateSentMany( await updateSentMany(shown, true)
pending.map((a) => a.id), }, [])
true,
)
await getInbox()
}, [getInbox])
useEffect(() => { useEffect(() => {
if ('Notification' in window && window.Notification.permission === 'default') { if ('Notification' in window && window.Notification.permission === 'default') {
window.Notification.requestPermission() window.Notification.requestPermission()
} }
// Sıra korunur: önce gösterilmemiş kayıtlar gösterilip "gönderildi" yazılır, sonra
// liste okunur. Eşzamanlı koşsalardı geç dönen istek erken dönenin sonucunu ezer ve
// rozet sunucudaki gerçekle ayrışırdı. Hata kullanıcı aksiyonu değildir; sessizce
// geçilir, bir sonraki tetikleme yeniden dener.
const refresh = () => {
lastPollAt.current = performance.now()
return getPushNotifications()
.then(() => getInbox())
.catch(() => undefined)
}
const poll = () => { const poll = () => {
// Sekme arka plandaysa istek atma. Pencerenin OS odağı yoksa (IDE'ye // Sekme arka plandaysa istek atma. Pencerenin OS odağı yoksa (IDE'ye
// geçmek, ikinci ekran, ayrık DevTools) sekme hâlâ görünür sayılır ve // geçmek, ikinci ekran, ayrık DevTools) sekme hâlâ görünür sayılır ve
@ -259,11 +296,16 @@ const _Notification = ({ className }: { className?: string }) => {
if (document.visibilityState === 'hidden') { if (document.visibilityState === 'hidden') {
return return
} }
lastPollAt.current = performance.now() refresh()
getInbox()
getPushNotifications()
} }
// Bildirim anında SignalR ile düşer; yoklama yalnızca bağlantı kurulamadığında ya da
// duyuru kaybolduğunda devreye giren yedektir.
const unsubscribeReceived = notificationSignalR.onReceived(() => {
refresh()
})
notificationSignalR.start()
poll() poll()
const intervalId = setInterval(poll, notificationInterval) const intervalId = setInterval(poll, notificationInterval)
@ -286,6 +328,8 @@ const _Notification = ({ className }: { className?: string }) => {
clearInterval(intervalId) clearInterval(intervalId)
document.removeEventListener('visibilitychange', onForeground) document.removeEventListener('visibilitychange', onForeground)
window.removeEventListener('focus', onForeground) window.removeEventListener('focus', onForeground)
unsubscribeReceived()
notificationSignalR.stop()
} }
}, [getInbox, getPushNotifications]) }, [getInbox, getPushNotifications])
@ -297,8 +341,17 @@ const _Notification = ({ className }: { className?: string }) => {
// Panelde görülen kayıtlar için ayrıca toast/desktop açılmasın. isSent // Panelde görülen kayıtlar için ayrıca toast/desktop açılmasın. isSent
// yalnızca tarayıcı kanallarında UI'nin sorumluluğunda; Mail/Sms gibi // yalnızca tarayıcı kanallarında UI'nin sorumluluğunda; Mail/Sms gibi
// kanalların gönderim durumu backend'e ait olduğu için dokunulmaz. // kanalların gönderim durumu backend'e ait olduğu için dokunulmaz.
// Masaüstü bildirimine izin verilmemişse o kayıt gösterilemez; "gönderildi"
// yazılırsa izin sonradan verildiğinde de hiç gösterilmezdi.
const desktopGranted =
'Notification' in window && window.Notification.permission === 'granted'
const unsentIds = items const unsentIds = items
.filter((a) => !a.isSent && pushChannels.includes(a.notificationChannel)) .filter(
(a) =>
!a.isSent &&
pushChannels.includes(a.notificationChannel) &&
(desktopGranted || a.notificationChannel !== NotificationChannels.Desktop),
)
.map((a) => a.id) .map((a) => a.id)
if (unsentIds.length > 0) { if (unsentIds.length > 0) {
unsentIds.forEach((id) => pushedIds.current.add(id)) unsentIds.forEach((id) => pushedIds.current.add(id))
@ -309,25 +362,25 @@ const _Notification = ({ className }: { className?: string }) => {
} }
}, [getInbox]) }, [getInbox])
// Sunucu, kullaniciya henuz iletilmemis (isSent === false) kaydi okundu saymaz; bu yuzden
// liste iyimser guncellenmez, isaretlemeden sonra sunucudan tazelenir. Aksi halde rozet
// sifirlanir ama bir sonraki yoklamada geri gelirdi.
const onMarkAllAsRead = useCallback(async () => { const onMarkAllAsRead = useCallback(async () => {
await Promise.all(inboxChannels.map((channel) => updateReadAll(channel, true))) await Promise.all(inboxChannels.map((channel) => updateReadAll(channel, true)))
await getInbox()
}, [getInbox])
setNotificationList((list) => list.map((item) => ({ ...item, readed: true }))) const onMarkAsRead = useCallback(
setUnreadNotificationCount(0) async (item: NotificationListItem) => {
}, [])
const onMarkAsRead = useCallback(async (item: NotificationListItem) => {
if (item.readed) { if (item.readed) {
return return
} }
await updateReadMany(item.ids, true) await updateReadMany(item.ids, true)
await getInbox()
setNotificationList((list) => },
list.map((a) => (a.ids[0] === item.ids[0] ? { ...a, readed: true } : a)), [getInbox],
) )
setUnreadNotificationCount((count) => Math.max(0, count - 1))
}, [])
const noResult = !loading && listLoaded.current && notificationList.length === 0 const noResult = !loading && listLoaded.current && notificationList.length === 0
@ -383,9 +436,9 @@ const _Notification = ({ className }: { className?: string }) => {
key={channel} key={channel}
prefix prefix
className="text-xs" className="text-xs"
prefixClass={NotificationChannelColors[channel]} prefixClass={NotificationChannelColors[channel] ?? 'bg-gray-400'}
> >
{channel} {translate(`::App.NotificationChannel.${channel}`)}
</Tag> </Tag>
))} ))}
</div> </div>
@ -410,8 +463,8 @@ const _Notification = ({ className }: { className?: string }) => {
src="/img/others/no-notification.png" src="/img/others/no-notification.png"
alt="no-notification" alt="no-notification"
/> />
<h6 className="font-semibold">No notifications!</h6> <h6 className="font-semibold">{translate('::App.Notifications.Empty')}</h6>
<p className="mt-1">Please Try again later</p> <p className="mt-1">{translate('::App.Notifications.EmptyHint')}</p>
</div> </div>
</div> </div>
)} )}
@ -432,6 +485,6 @@ const _Notification = ({ className }: { className?: string }) => {
) )
} }
const Notification = withHeaderItem(_Notification) const Notification = withHeaderItem(NotificationDropdown)
export default Notification export default Notification

View file

@ -6,24 +6,43 @@ export interface NotificationDto {
notificationType: string notificationType: string
message: string message: string
isRead: boolean isRead: boolean
/** Kullanıcıya iletildi mi; okundu işareti yalnızca iletilmiş kayıtta yazılabilir. */
isSent: boolean isSent: boolean
readTime: Date /** Okundu işaretinin zamanı; okunmamış kayıtta null gelir. */
readTime?: string | null
creationTime: Date creationTime: Date
creatorFullname?: string creatorFullname?: string
creatorId?: string creatorId?: string
tenantId?: string tenantId?: string
} }
export interface NotificationRuleSummaryDto {
id: string
notificationTypeName: string
channel: string
recipientType: string
recipientId?: string
isActive: boolean
}
export interface NotificationTypeDto {
id: string
name: string
/** Türün ne işe yaradığı; satır başlığında ipucu olarak gösterilir. */
description?: string | null
}
export interface NotificationRuleDto { export interface NotificationRuleDto {
id: string id: string
notificationType: string /** Tercih eşlemesi ad üzerinden değil id üzerinden yapılır; ad yalnızca ekranda gösterilir. */
notificationTypeId: string
channel: string channel: string
isActive: boolean isActive: boolean
isFixed: boolean isFixed: boolean
isCustomized: boolean isCustomized: boolean
} }
export declare class NotificationFilterRequestDto extends PagedAndSortedResultRequestDto { export interface NotificationFilterRequestDto extends PagedAndSortedResultRequestDto {
channels?: string[] channels?: string[]
isRead?: boolean isRead?: boolean
isSent?: boolean isSent?: boolean

View file

@ -1,12 +1,22 @@
import { NotificationRuleDto } from '../proxy/notification/models' import {
NotificationRuleDto,
NotificationRuleSummaryDto,
NotificationTypeDto,
} from '../proxy/notification/models'
import apiService from './api.service' import apiService from './api.service'
export const getMyNotificationTypes = () => export const getMyNotificationTypes = () =>
apiService.fetchData<string[]>({ apiService.fetchData<NotificationTypeDto[]>({
method: 'GET', method: 'GET',
url: `/api/app/notification-rule/my-notification-types`, url: `/api/app/notification-rule/my-notification-types`,
}) })
export const getNotificationRuleSummary = (ruleId: string) =>
apiService.fetchData<NotificationRuleSummaryDto>({
method: 'GET',
url: `/api/app/notification-rule/rule-summary/${ruleId}`,
})
export const getMyNotificationRules = () => export const getMyNotificationRules = () =>
apiService.fetchData<NotificationRuleDto[]>({ apiService.fetchData<NotificationRuleDto[]>({
method: 'GET', method: 'GET',
@ -15,7 +25,7 @@ export const getMyNotificationRules = () =>
export const postMyNotificationRule = (data: { export const postMyNotificationRule = (data: {
channel: string channel: string
notificationType: string notificationTypeId: string
isActive: boolean isActive: boolean
}) => }) =>
apiService.fetchData<NotificationRuleDto[]>({ apiService.fetchData<NotificationRuleDto[]>({
@ -24,9 +34,9 @@ export const postMyNotificationRule = (data: {
data, data,
}) })
export const deleteMyNotificationRules = (notificationType: string) => export const deleteMyNotificationRules = (notificationTypeId: string) =>
apiService.fetchData<NotificationRuleDto[]>({ apiService.fetchData<NotificationRuleDto[]>({
method: 'DELETE', method: 'DELETE',
url: `/api/app/notification-rule/my-notification-rules`, url: `/api/app/notification-rule/my-notification-rules`,
params: { notificationType }, params: { notificationTypeId },
}) })

View file

@ -15,13 +15,6 @@ export const getList = (params: NotificationFilterRequestDto, config?: Partial<C
{ apiName: 'Default', ...config }, { apiName: 'Default', ...config },
) )
export const updateRead = (notificationId: string, isRead: boolean) =>
apiService.fetchData<NotificationDto>({
method: 'PUT',
url: `/api/app/notification/read/${notificationId}`,
params: { isRead },
})
export const updateReadAll = (notificationChannel: string, isRead: boolean) => export const updateReadAll = (notificationChannel: string, isRead: boolean) =>
apiService.fetchData<number>({ apiService.fetchData<number>({
method: 'PUT', method: 'PUT',

View file

@ -0,0 +1,65 @@
import { store } from '@/store/store'
import * as signalR from '@microsoft/signalr'
/** Sunucunun duyuruda gönderdiği yük; hangi kanallarda kayıt oluştuğunu bildirir. */
export type NotificationReceivedDto = {
channels: string[]
}
type ReceivedHandler = (payload: NotificationReceivedDto) => void
/**
* Bildirimlerin anında düşmesi için kurulan bağlantı. İstemci yalnızca dinler: duyuruyu alınca
* kendi listesini sunucudan okur, böylece okundu/gönderildi bayrakları tek kaynaktan gelir.
* Bağlantı kurulamazsa bildirim kaybolmaz — dropdown'daki yedek yoklama aynı kaydı getirir.
*/
class NotificationSignalRService {
private connection?: signalR.HubConnection
private receivedHandlers = new Set<ReceivedHandler>()
private createConnection() {
this.connection = new signalR.HubConnectionBuilder()
.withUrl(`${import.meta.env.VITE_API_URL}/notificationhub`, {
accessTokenFactory: () => store.getState().auth.session.token || '',
})
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Warning)
.build()
this.connection.on('NotificationReceived', (payload: NotificationReceivedDto) => {
this.receivedHandlers.forEach((handler) => handler(payload))
})
}
async start() {
if (!store.getState().auth.session.signedIn) return
if (!this.connection) {
this.createConnection()
}
if (this.connection?.state !== signalR.HubConnectionState.Disconnected) return
try {
await this.connection.start()
} catch {
// Bağlantı kurulamadı; yoklama yedeği devrede kalır.
}
}
async stop() {
if (!this.connection) return
const connection = this.connection
this.connection = undefined
await connection.stop().catch(() => undefined)
}
onReceived(handler: ReceivedHandler) {
this.receivedHandlers.add(handler)
return () => this.receivedHandlers.delete(handler)
}
}
export const notificationSignalR = new NotificationSignalRService()

View file

@ -31,7 +31,19 @@ type SwEvent = Event & {
source?: { postMessage: (message: unknown) => void } | null source?: { postMessage: (message: unknown) => void } | null
} }
type SwClient = { id: string; postMessage: (message: unknown) => void } type SwClient = {
id: string
url?: string
focus?: () => Promise<SwClient>
navigate?: (url: string) => Promise<SwClient>
postMessage: (message: unknown) => void
}
/** Masaüstü bildirimine tıklandığında gelen olay; `data` gösterim sırasında yazılan yüktür. */
type SwNotificationEvent = Event & {
waitUntil: (promise: Promise<unknown>) => void
notification: { close: () => void; data?: { url?: string } | null }
}
declare const self: { declare const self: {
__WB_MANIFEST: PrecacheEntry[] __WB_MANIFEST: PrecacheEntry[]
@ -44,6 +56,12 @@ declare const self: {
matchAll: (options?: { includeUncontrolled?: boolean; type?: string }) => Promise<SwClient[]> matchAll: (options?: { includeUncontrolled?: boolean; type?: string }) => Promise<SwClient[]>
} }
addEventListener: (type: string, listener: (event: SwEvent) => void) => void addEventListener: (type: string, listener: (event: SwEvent) => void) => void
clients_openWindow?: never
}
declare const selfWithNotifications: {
clients: { openWindow: (url: string) => Promise<SwClient | null> }
addEventListener: (type: 'notificationclick', listener: (event: SwNotificationEvent) => void) => void
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@ -704,3 +722,45 @@ self.addEventListener('fetch', (event) => {
event.respondWith(cacheFirst(request, IMAGE_CACHE, IMAGE_LIMIT)) event.respondWith(cacheFirst(request, IMAGE_CACHE, IMAGE_LIMIT))
} }
}) })
/* ------------------------------------------------------------------ */
/* Bildirim tıklaması */
/* ------------------------------------------------------------------ */
/**
* Masaüstü bildirimine tıklayınca uygulamaya dönülür: açık bir sekme varsa öne alınıp
* hedef adrese götürülür, yoksa yeni sekme açılır. Hedef, bildirimi gösterirken yazılan
* `data.url` değeridir; yoksa etkinlik kaydı ekranı açılır.
*/
const NOTIFICATION_FALLBACK_URL = '/admin/activityLog'
;(self as unknown as typeof selfWithNotifications).addEventListener(
'notificationclick',
(event) => {
event.notification.close()
const target = event.notification.data?.url || NOTIFICATION_FALLBACK_URL
const targetUrl = new URL(target, self.location.origin).href
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
for (const client of clients) {
if (!client.url) continue
if (client.navigate) {
await client.navigate(targetUrl).catch(() => undefined)
}
if (client.focus) {
await client.focus().catch(() => undefined)
}
return
}
await (self as unknown as typeof selfWithNotifications).clients
.openWindow(targetUrl)
.catch(() => undefined)
})(),
)
},
)

View file

@ -4,7 +4,7 @@ import Drawer from '@/components/ui/Drawer'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import NotificationChannels from '@/constants/notification-channel.enum' import NotificationChannels from '@/constants/notification-channel.enum'
import { NotificationDto } from '@/proxy/notification/models' import { NotificationDto } from '@/proxy/notification/models'
import { getList } from '@/services/notification.service' import { getList, updateReadAll } from '@/services/notification.service'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { groupBy } from '@/utils/collection' import { groupBy } from '@/utils/collection'
@ -26,6 +26,7 @@ const ActivityLog = () => {
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false) const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false)
const [markingRead, setMarkingRead] = useState(false)
const direction = useStoreState((state) => state.theme.direction) const direction = useStoreState((state) => state.theme.direction)
const [filter, setFilter] = useState<string[]>([ const [filter, setFilter] = useState<string[]>([
NotificationChannels.Desktop, NotificationChannels.Desktop,
@ -59,7 +60,14 @@ const ActivityLog = () => {
const next: NotificationGroups = { ...prev } const next: NotificationGroups = { ...prev }
for (const [key, value] of Object.entries(logs)) { for (const [key, value] of Object.entries(logs)) {
next[key] = [...(next[key] ?? []), ...value] // Sayfalama canli veri uzerinde calisir; araya yeni bildirim girdiginde ayni
// kayit ikinci sayfada tekrar gelebilir. Id bazli eleme olmadan liste kaydi iki
// kez cizer ve React ayni anahtari iki kez gorur.
const existingIds = new Set((next[key] ?? []).map((item) => item.id))
next[key] = [
...(next[key] ?? []),
...value.filter((item) => !existingIds.has(item.id)),
]
} }
return next return next
@ -76,6 +84,21 @@ const ActivityLog = () => {
fetchData(page > 0) fetchData(page > 0)
}, [fetchData, page]) }, [fetchData, page])
/**
* Ekranda görülen kayıtlar okundu işaretlenmeden rozetteki sayı düşmüyordu. İşaretleme
* süzülen kanallarla sınırlıdır; sunucu iletilmemiş kayıtları zaten atlar.
*/
const handleMarkAllAsRead = async () => {
setMarkingRead(true)
try {
await Promise.all(filter.map((channel) => updateReadAll(channel, true)))
setPage(0)
await fetchData()
} finally {
setMarkingRead(false)
}
}
const handleFilterChange = (value: string[]) => { const handleFilterChange = (value: string[]) => {
setPage(0) setPage(0)
setNotifications({}) setNotifications({})
@ -94,6 +117,16 @@ const ActivityLog = () => {
<h3 className="text-xl font-semibold md:text-2xl text-gray-900 dark:text-white"> <h3 className="text-xl font-semibold md:text-2xl text-gray-900 dark:text-white">
{translate('::Abp.Identity.ActivityLogs')} {translate('::Abp.Identity.ActivityLogs')}
</h3> </h3>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="twoTone"
loading={markingRead}
className="dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
onClick={handleMarkAllAsRead}
>
{translate('::Abp.Identity.ActivityLogs.MarkAllRead')}
</Button>
<Button <Button
className="lg:hidden dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" className="lg:hidden dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
size="sm" size="sm"
@ -103,6 +136,7 @@ const ActivityLog = () => {
{translate('::Abp.Identity.ActivityLogs.Filters')} {translate('::Abp.Identity.ActivityLogs.Filters')}
</Button> </Button>
</div> </div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_340px]"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="min-w-0 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 lg:p-6"> <div className="min-w-0 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 lg:p-6">

View file

@ -8,7 +8,6 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
type EventProps = { type EventProps = {
data: NotificationDto data: NotificationDto
compact?: boolean
} }
const HighlightedText = ({ children, className }: CommonProps) => { const HighlightedText = ({ children, className }: CommonProps) => {
@ -19,35 +18,12 @@ const HighlightedText = ({ children, className }: CommonProps) => {
) )
} }
const Event = ({ data, compact }: EventProps) => { const Event = ({ data }: EventProps) => {
const { translate } = useLocalization() const { translate } = useLocalization()
return ( return (
<> <>
{compact ? ( <div className="my-1 flex flex-wrap items-center">
<>
<div className="flex flex-col gap-y-0.5">
<HighlightedText>{data.creatorFullname}</HighlightedText>
<span className="text-xs">{dayjs(data.creationTime).format('hh:mm A')}</span>
</div>
<div className="mt-4">
<span className="mx-1">
{translate('::Abp.Identity.ActivityLogs.SentNotificationOfType', {
type: data.notificationType,
})}{' '}
</span>
<Tag
prefix
className="mx-1"
prefixClass={`${NotificationChannelColors[data.notificationChannel]}`}
>
{data.notificationChannel}
</Tag>
</div>
</>
) : (
<div className="my-1 flex items-center">
<div> <div>
<HighlightedText>{data.notificationType}</HighlightedText>{' '} <HighlightedText>{data.notificationType}</HighlightedText>{' '}
{translate('::Abp.Identity.ActivityLogs.SentNotification')} {translate('::Abp.Identity.ActivityLogs.SentNotification')}
@ -56,14 +32,15 @@ const Event = ({ data, compact }: EventProps) => {
<Tag <Tag
prefix prefix
className="mx-1" className="mx-1"
prefixClass={`${NotificationChannelColors[data.notificationChannel]}`} prefixClass={NotificationChannelColors[data.notificationChannel] ?? 'bg-gray-400'}
> >
{data.notificationChannel} {translate(`::App.NotificationChannel.${data.notificationChannel}`)}
</Tag> </Tag>
{data.creatorFullname && <span className="ml-1">{data.creatorFullname}</span>}
<span className="ml-3 rtl:mr-3">{dayjs(data.creationTime).format('LT')}</span> <span className="ml-3 rtl:mr-3">{dayjs(data.creationTime).format('LT')}</span>
</div> </div>
)}
{data.message && ( {data.message && (
<Card bordered className="mt-4"> <Card bordered className="mt-4">
{data.message} {data.message}

View file

@ -48,7 +48,7 @@ const Log = ({
} }
className="dark:bg-gray-800" className="dark:bg-gray-800"
> >
<Event data={notification} compact={false} /> <Event data={notification} />
</Timeline.Item> </Timeline.Item>
)) ))
)} )}

View file

@ -5,20 +5,24 @@ import {
FormItem, FormItem,
Input, Input,
Notification, Notification,
Spinner,
Tag,
toast, toast,
} from '@/components/ui' } from '@/components/ui'
import { NotificationChannelColors } from '@/constants/notification-channel.enum'
import { NotificationRuleSummaryDto } from '@/proxy/notification/models'
import { postMyNotificationByNotificationRuleId } from '@/services/notification.service' import { postMyNotificationByNotificationRuleId } from '@/services/notification.service'
import { getNotificationRuleSummary } from '@/services/notification-rule.service'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { Field, Form, Formik, FormikHelpers } from 'formik' import { Field, Form, Formik, FormikHelpers } from 'formik'
import { useEffect, useState } from 'react'
import * as Yup from 'yup' import * as Yup from 'yup'
interface CreateNotificationValues { interface CreateNotificationValues {
id: string
message: string message: string
} }
const scheme = Yup.object().shape({ const scheme = Yup.object().shape({
id: Yup.string().required(),
message: Yup.string().required(), message: Yup.string().required(),
}) })
@ -33,14 +37,43 @@ function CreateNotification({
}) { }) {
const { translate } = useLocalization() const { translate } = useLocalization()
// Diyalog yalnızca kural kimliğini alır; kimin, hangi olayı, hangi kanaldan alacağı
// sunucudan okunur — kullanıcı ham GUID'e bakarak karar veremez.
const [summary, setSummary] = useState<NotificationRuleSummaryDto | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
const fetchSummary = async () => {
setLoading(true)
try {
const response = await getNotificationRuleSummary(id)
if (!cancelled) {
setSummary(response.data)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
if (id) {
fetchSummary()
} else {
setLoading(false)
}
return () => {
cancelled = true
}
}, [id])
const handleSubmit = async ( const handleSubmit = async (
values: CreateNotificationValues, values: CreateNotificationValues,
{ setSubmitting }: FormikHelpers<CreateNotificationValues>, { setSubmitting }: FormikHelpers<CreateNotificationValues>,
) => { ) => {
if (!id) {
return
}
try { try {
await postMyNotificationByNotificationRuleId({ id, message: values.message }) await postMyNotificationByNotificationRuleId({ id, message: values.message })
toast.push( toast.push(
@ -64,14 +97,59 @@ function CreateNotification({
return ( return (
<Dialog isOpen={open} onClose={onDialogClose} onRequestClose={onDialogClose}> <Dialog isOpen={open} onClose={onDialogClose} onRequestClose={onDialogClose}>
<h5 className="mb-4">{id}</h5> <h5 className="mb-1">
<Formik initialValues={{ id, message: '' }} validationSchema={scheme} onSubmit={handleSubmit}> {summary?.notificationTypeName || translate('::App.Notifications.CreateTitle')}
</h5>
<p className="mb-4 text-xs">{translate('::App.Notifications.CreateHint')}</p>
{loading ? (
<div className="flex items-center justify-center py-6">
<Spinner size={30} />
</div>
) : (
<>
{summary && (
<div className="mb-4 grid grid-cols-2 gap-2 text-sm">
<div>
<span className="font-semibold">
{translate('::App.Listform.ListformField.Channel')}:{' '}
</span>
<Tag prefix prefixClass={NotificationChannelColors[summary.channel] ?? 'bg-gray-400'}>
{translate(`::App.NotificationChannel.${summary.channel}`)}
</Tag>
</div>
<div>
<span className="font-semibold">
{translate('::App.Listform.ListformField.IsActive')}:{' '}
</span>
{translate(summary.isActive ? '::App.Platform.Yes' : '::App.Platform.No')}
</div>
<div>
<span className="font-semibold">
{translate('::App.Listform.ListformField.RecipientType')}:{' '}
</span>
{summary.recipientType}
</div>
<div>
<span className="font-semibold">
{translate('::App.Listform.ListformField.RecipientId')}:{' '}
</span>
{summary.recipientId || '-'}
</div>
</div>
)}
<Formik
initialValues={{ message: '' }}
validationSchema={scheme}
onSubmit={handleSubmit}
>
{({ touched, errors, isSubmitting }) => { {({ touched, errors, isSubmitting }) => {
return ( return (
<Form> <Form>
<FormContainer size="sm"> <FormContainer size="sm">
<FormItem <FormItem
label="Message" label={translate('::App.Listform.ListformField.Message')}
invalid={errors.message && touched.message} invalid={errors.message && touched.message}
errorMessage={errors.message} errorMessage={errors.message}
> >
@ -85,8 +163,16 @@ function CreateNotification({
</FormItem> </FormItem>
<div className="mt-6 flex flex-row justify-end gap-3"> <div className="mt-6 flex flex-row justify-end gap-3">
<Button size="sm" variant="solid" loading={isSubmitting} type="submit"> <Button
{isSubmitting ? translate('::App.Platform.Saving') : translate('::App.Platform.Save')} size="sm"
variant="solid"
loading={isSubmitting}
disabled={!id}
type="submit"
>
{isSubmitting
? translate('::App.Platform.Saving')
: translate('::App.Platform.Save')}
</Button> </Button>
<Button size="sm" type="button" variant="plain" onClick={onDialogClose}> <Button size="sm" type="button" variant="plain" onClick={onDialogClose}>
{translate('::App.Platform.Cancel')} {translate('::App.Platform.Cancel')}
@ -97,6 +183,8 @@ function CreateNotification({
) )
}} }}
</Formik> </Formik>
</>
)}
</Dialog> </Dialog>
) )
} }

View file

@ -1,52 +1,62 @@
import { Button, Checkbox, Spinner, Table } from '@/components/ui' import { Button, Checkbox, Spinner, Table } from '@/components/ui'
import NotificationChannels from '@/constants/notification-channel.enum' import NotificationChannels from '@/constants/notification-channel.enum'
import { NotificationRuleDto } from '@/proxy/notification/models' import { NotificationRuleDto, NotificationTypeDto } from '@/proxy/notification/models'
import { import {
deleteMyNotificationRules, deleteMyNotificationRules,
getMyNotificationRules, getMyNotificationRules,
getMyNotificationTypes, getMyNotificationTypes,
postMyNotificationRule, postMyNotificationRule,
} from '@/services/notification-rule.service' } from '@/services/notification-rule.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { FaHistory } from 'react-icons/fa' import { FaHistory } from 'react-icons/fa'
const { Tr, Th, Td, THead, TBody } = Table const { Tr, Th, Td, THead, TBody } = Table
/** Matris sütunları kanal kayıt defterinden gelir; yeni bir kanal eklendiğinde ekran kendiliğinden güncel olur. */
const channels = Object.values(NotificationChannels)
const NotificationSettings = () => { const NotificationSettings = () => {
const { translate } = useLocalization()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [list, setList] = useState<NotificationRuleDto[][]>([]) const [list, setList] = useState<NotificationRuleDto[][]>([])
const [notificationTypes, setNotificationTypes] = useState<string[]>([]) const [notificationTypes, setNotificationTypes] = useState<NotificationTypeDto[]>([])
const [channels, setChannels] = useState<string[]>([])
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true) setLoading(true)
const channels = Object.keys(NotificationChannels) try {
setChannels(channels) const [typesResponse, rulesResponse] = await Promise.all([
const response = await getMyNotificationTypes() getMyNotificationTypes(),
const notificationTypes = response.data getMyNotificationRules(),
setNotificationTypes(response.data) ])
const responseRules = await getMyNotificationRules()
const rules = responseRules.data const types = typesResponse.data ?? []
const finalList: NotificationRuleDto[][] = [] const rules = rulesResponse.data ?? []
for (const notificationType of notificationTypes) {
const x: NotificationRuleDto[] = [] setNotificationTypes(types)
for (const channel of channels) { setList(
const rule = rules.find( types.map((type) =>
(a) => a.channel === channel && a.notificationType === notificationType, channels.map(
) (channel) =>
x.push( rules.find(
rule ?? (rule) => rule.channel === channel && rule.notificationTypeId === type.id,
({ ) ?? {
id: '',
notificationTypeId: type.id,
channel, channel,
notificationType,
isActive: false, isActive: false,
} as NotificationRuleDto), isFixed: false,
isCustomized: false,
},
),
),
) )
} } finally {
finalList.push(x) // Hata durumunda da spinner kapanır; aksi hâlde ekran kalıcı olarak yükleniyor görünürdü.
}
setList(finalList)
setLoading(false) setLoading(false)
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@ -54,17 +64,29 @@ const NotificationSettings = () => {
}, [fetchData]) }, [fetchData])
const onClickCheckbox = async (checked: boolean, rule: NotificationRuleDto) => { const onClickCheckbox = async (checked: boolean, rule: NotificationRuleDto) => {
// Kayıt sürerken kutular kilitlenir; art arda tıklamada son dönen yanıt kazanır ve
// ekran sunucudaki gerçeğin tersine düşerdi.
setSaving(true)
try {
await postMyNotificationRule({ await postMyNotificationRule({
channel: rule.channel, channel: rule.channel,
notificationType: rule.notificationType, notificationTypeId: rule.notificationTypeId,
isActive: checked, isActive: checked,
}) })
await fetchData() await fetchData()
} finally {
setSaving(false)
}
} }
const resetMyNotifications = async (notificationType: string) => { const resetMyNotifications = async (notificationTypeId: string) => {
await deleteMyNotificationRules(notificationType) setSaving(true)
try {
await deleteMyNotificationRules(notificationTypeId)
await fetchData() await fetchData()
} finally {
setSaving(false)
}
} }
return ( return (
@ -80,36 +102,37 @@ const NotificationSettings = () => {
<THead> <THead>
<Tr> <Tr>
<Th></Th> <Th></Th>
{channels.map((a) => ( {channels.map((channel) => (
<Th key={a} className="text-center"> <Th key={channel} className="text-center">
{a} {translate(`::App.NotificationChannel.${channel}`)}
</Th> </Th>
))} ))}
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{notificationTypes.map((a, m) => ( {notificationTypes.map((type, m) => (
<Tr key={a}> <Tr key={type.id}>
<Th> <Th>
<Button <Button
shape="circle" shape="circle"
variant="plain" variant="plain"
type="button" type="button"
size="sm" size="sm"
title="Reset" disabled={saving}
title={translate('::Abp.Identity.NotificationSettings.Reset')}
icon={<FaHistory />} icon={<FaHistory />}
onClick={async (e) => { onClick={async (e) => {
e.preventDefault() e.preventDefault()
resetMyNotifications(a) resetMyNotifications(type.id)
}} }}
/> />
{a} <span title={type.description ?? undefined}>{type.name}</span>
</Th> </Th>
{channels.map((b, n) => ( {channels.map((channel, n) => (
<Td key={a + b} className="text-center"> <Td key={type.id + channel} className="text-center">
<Checkbox <Checkbox
name={a + b} name={type.id + channel}
disabled={list[m][n].isFixed} disabled={list[m][n].isFixed || saving}
checked={list[m][n].isActive} checked={list[m][n].isActive}
color={list[m][n].isCustomized ? 'red-500' : undefined} color={list[m][n].isCustomized ? 'red-500' : undefined}
onChange={(e) => onClickCheckbox(e, list[m][n])} onChange={(e) => onClickCheckbox(e, list[m][n])}
@ -121,7 +144,9 @@ const NotificationSettings = () => {
</TBody> </TBody>
</Table> </Table>
) : ( ) : (
<></> <p className="text-center">
{translate('::Abp.Identity.NotificationSettings.NoRule')}
</p>
)} )}
</> </>
)} )}