352 lines
16 KiB
C#
352 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Sozsoft.Notifications.Entities;
|
|
using Sozsoft.Notifications.Enums;
|
|
using Volo.Abp;
|
|
using Volo.Abp.DependencyInjection;
|
|
using Volo.Abp.Domain.Repositories;
|
|
using Volo.Abp.Identity;
|
|
using Volo.Abp.MultiTenancy;
|
|
using Volo.Abp.Timing;
|
|
|
|
namespace Sozsoft.Notifications.Domain;
|
|
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Alici kumesi kurallardan degil ekrandan gelen gonderim: secilen her kullanici icin secilen
|
|
/// her kanalda bir bildirim kaydi uretir. Kanaldan cikis yine <c>NotificationWorker</c>'a
|
|
/// aittir; kullanici tercihi dikkate alinmaz, cunku gonderim zaten bilincli bir aksiyondur.
|
|
/// </summary>
|
|
Task<ManualNotificationResult> SendManualNotificationAsync(ManualNotificationRequest request);
|
|
}
|
|
|
|
/// <summary>Manuel gonderim istegi; alicilar ve kanallar cagirandan gelir.</summary>
|
|
public class ManualNotificationRequest
|
|
{
|
|
public Guid NotificationTypeId { get; set; }
|
|
|
|
/// <summary>Metnin geldigi sablon; secilmediyse bos. Uretilen her satira provenans olarak yazilir.</summary>
|
|
public Guid? NotificationTemplateId { get; set; }
|
|
public IReadOnlyCollection<Guid> UserIds { get; set; } = [];
|
|
public IReadOnlyCollection<string> Channels { get; set; } = [];
|
|
public string Message { get; set; } = string.Empty;
|
|
|
|
/// <summary>Mail konu satiri; bos ise konu bildirim tipinin adindan uretilir.</summary>
|
|
public string Subject { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Manuel gonderimin sonucu. Atlananlar kanal kanal raporlanir: bir alicinin o kanalda adresi
|
|
/// (e-posta, telefon, chat id) yoksa bildirim uretilemez ve bunun cozumu kullanici kaydindadir.
|
|
/// </summary>
|
|
public class ManualNotificationResult
|
|
{
|
|
public int SentCount { get; set; }
|
|
|
|
/// <summary>Uretilen ilk kaydin kimligi; ekran gonderimden sonra bu satira konumlanir.</summary>
|
|
public Guid FirstNotificationId { get; set; }
|
|
public Dictionary<string, int> SkippedByChannel { get; set; } = [];
|
|
public int SkippedCount => SkippedByChannel.Values.Sum();
|
|
}
|
|
|
|
public class NotificationManager : INotificationManager
|
|
{
|
|
private readonly IReadOnlyRepository<NotificationRule> repository;
|
|
private readonly IReadOnlyRepository<IdentityUser> repositoryUser;
|
|
private readonly IReadOnlyRepository<IdentityRole> repositoryRole;
|
|
private readonly IRepository<Notification, Guid> repositoryNotification;
|
|
private readonly INotificationIdentifierProvider notificationIdentifierProvider;
|
|
private readonly IOrganizationUnitRepository repositoryOrganizationUnit;
|
|
private readonly INotificationRealtimeNotifier realtimeNotifier;
|
|
private readonly ICurrentTenant currentTenant;
|
|
private readonly IClock clock;
|
|
|
|
public NotificationManager(
|
|
IReadOnlyRepository<NotificationRule> repository,
|
|
IReadOnlyRepository<IdentityUser> repositoryUser,
|
|
IReadOnlyRepository<IdentityRole> repositoryRole,
|
|
IRepository<Notification, Guid> repositoryNotification,
|
|
INotificationIdentifierProvider notificationIdentifierProvider,
|
|
IOrganizationUnitRepository repositoryOrganizationUnit,
|
|
INotificationRealtimeNotifier realtimeNotifier,
|
|
ICurrentTenant currentTenant,
|
|
IClock clock
|
|
)
|
|
{
|
|
this.repository = repository;
|
|
this.repositoryUser = repositoryUser;
|
|
this.repositoryRole = repositoryRole;
|
|
this.repositoryNotification = repositoryNotification;
|
|
this.notificationIdentifierProvider = notificationIdentifierProvider;
|
|
this.repositoryOrganizationUnit = repositoryOrganizationUnit;
|
|
this.realtimeNotifier = realtimeNotifier;
|
|
this.currentTenant = currentTenant;
|
|
this.clock = clock;
|
|
}
|
|
|
|
public async Task SendNotificationAsync(Guid NotificationTypeId, string NotificationMessage)
|
|
{
|
|
var rules = await repository.GetListAsync(a => a.NotificationTypeId == NotificationTypeId);
|
|
|
|
// 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();
|
|
|
|
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 SaveAndAnnounceAsync(notifications);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Alicilari kurallar yerine cagiranin verdigi gonderim. Kullanici tercihi okunmaz: manuel
|
|
/// gonderim zorunlu bildirim (<c>IsFixed</c>) gibi davranir, aksi halde yonetici bilincli
|
|
/// yaptigi gonderimin kime gitmedigini hic ogrenemezdi.
|
|
/// </summary>
|
|
public async Task<ManualNotificationResult> SendManualNotificationAsync(ManualNotificationRequest request)
|
|
{
|
|
var result = new ManualNotificationResult();
|
|
|
|
var channels = (request.Channels ?? [])
|
|
.Where(channel => !channel.IsNullOrWhiteSpace())
|
|
.Select(channel => channel.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
var userIds = (request.UserIds ?? []).Distinct().ToList();
|
|
|
|
if (channels.Count == 0 || userIds.Count == 0 || request.Message.IsNullOrWhiteSpace())
|
|
{
|
|
return result;
|
|
}
|
|
|
|
// Adm_T_Notification.NotificationRuleId NOT NULL'dur. Manuel gonderim kural motorundan
|
|
// gecmedigi icin tipin capa kuralina baglanir; kural pasiftir, alici kumesini o belirlemez.
|
|
var anchorRule = await repository.FirstOrDefaultAsync(rule =>
|
|
rule.NotificationTypeId == request.NotificationTypeId
|
|
&& rule.RecipientType == NotificationRecipientTypes.Custom)
|
|
?? await repository.FirstOrDefaultAsync(rule => rule.NotificationTypeId == request.NotificationTypeId)
|
|
?? throw new BusinessException("Notifications:ManualAnchorRuleNotFound")
|
|
.WithData("NotificationTypeId", request.NotificationTypeId);
|
|
|
|
// Kullanici sorgusu ABP kiraci filtresinden gecer; istekteki kimlikler degistirilse bile
|
|
// baska bir kiracinin kullanicisina bildirim uretilemez.
|
|
var queryUsers = await repositoryUser.GetQueryableAsync();
|
|
var users = await queryUsers
|
|
.Where(user => userIds.Contains(user.Id) && user.IsActive)
|
|
.Select(user => new ManualRecipient(user.Id, user.UserName, user.Name, user.Surname, user.Email))
|
|
.ToListAsync();
|
|
|
|
var notifications = new List<Notification>();
|
|
foreach (var channel in channels)
|
|
{
|
|
foreach (var user in users)
|
|
{
|
|
var identifier = await notificationIdentifierProvider.GetIdentifier(user.Id, channel);
|
|
if (identifier.IsNullOrWhiteSpace())
|
|
{
|
|
result.SkippedByChannel[channel] = result.SkippedByChannel.GetOrDefault(channel) + 1;
|
|
continue;
|
|
}
|
|
|
|
notifications.Add(new Notification
|
|
{
|
|
NotificationTypeId = request.NotificationTypeId,
|
|
NotificationTemplateId = request.NotificationTemplateId,
|
|
NotificationRuleId = anchorRule.Id,
|
|
NotificationChannel = channel,
|
|
Identifier = identifier!,
|
|
UserId = user.Id,
|
|
Message = ResolveTokens(request.Message, user),
|
|
Subject = request.Subject,
|
|
IsSent = IsDeliveredOnCreate(channel),
|
|
IsRead = false
|
|
});
|
|
}
|
|
}
|
|
|
|
result.SentCount = notifications.Count;
|
|
await SaveAndAnnounceAsync(notifications);
|
|
result.FirstNotificationId = notifications.Count == 0 ? Guid.Empty : notifications[0].Id;
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Mesajdaki yer tutucular alici bazinda cozulur; her alici kendi metnini alir.</summary>
|
|
private string ResolveTokens(string message, ManualRecipient recipient)
|
|
{
|
|
var now = clock.Now;
|
|
var resolved = message
|
|
.Replace(NotificationMessageTokens.FullName, $"{recipient.Name} {recipient.Surname}".Trim())
|
|
.Replace(NotificationMessageTokens.UserName, recipient.UserName ?? string.Empty)
|
|
.Replace(NotificationMessageTokens.Email, recipient.Email ?? string.Empty)
|
|
.Replace(NotificationMessageTokens.Date, now.ToShortDateString())
|
|
.Replace(NotificationMessageTokens.Time, now.ToShortTimeString())
|
|
.Replace(NotificationMessageTokens.Tenant, currentTenant.Name ?? string.Empty);
|
|
|
|
// Yer tutucular cozulunce metin uzayabilir; kayit kolonu 2048 karakterdir.
|
|
return resolved.Length > 2048 ? resolved[..2048] : resolved;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Kayitlari yazar ve acik istemcilere duyurur. Duyuru kayit yazildiktan sonra yapilir;
|
|
/// istemci duyuruyu alip listesini okudugunda kaydi mutlaka gormeli. Duyuru gitmezse
|
|
/// istemcideki yedek yoklama ayni kaydi getirir.
|
|
/// </summary>
|
|
private async Task SaveAndAnnounceAsync(List<Notification> notifications)
|
|
{
|
|
if (notifications.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await repositoryNotification.InsertManyAsync(notifications, autoSave: true);
|
|
|
|
await realtimeNotifier.NotifyAsync(
|
|
[.. notifications
|
|
.Where(notification => notification.UserId.HasValue)
|
|
.Select(notification => new NotificationDelivery(notification.UserId!.Value, notification.NotificationChannel))]);
|
|
}
|
|
|
|
/// <summary>Yer tutucularin cozulmesi icin gereken alici bilgisi.</summary>
|
|
private sealed record ManualRecipient(Guid Id, string UserName, string Name, string Surname, string Email);
|
|
|
|
/// <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 = [];
|
|
|
|
if (notificationRule.RecipientType == NotificationRecipientTypes.All)
|
|
{
|
|
userIds.AddRange(await queryUsers
|
|
.Where(user => user.IsActive)
|
|
.Select(user => user.Id)
|
|
.ToListAsync());
|
|
}
|
|
else if (notificationRule.RecipientType == NotificationRecipientTypes.User)
|
|
{
|
|
var user = await queryUsers.FirstOrDefaultAsync(user => user.UserName == notificationRule.RecipientId);
|
|
if (user != null)
|
|
{
|
|
userIds.Add(user.Id);
|
|
}
|
|
}
|
|
else if (notificationRule.RecipientType == NotificationRecipientTypes.Role)
|
|
{
|
|
var role = await repositoryRole.FirstOrDefaultAsync(role => role.Name == notificationRule.RecipientId);
|
|
if (role != null)
|
|
{
|
|
userIds.AddRange(await queryUsers
|
|
.Where(user => user.Roles.Any(userRole => userRole.RoleId == role.Id) && user.IsActive)
|
|
.Select(user => user.Id)
|
|
.ToListAsync());
|
|
}
|
|
}
|
|
else if (notificationRule.RecipientType == NotificationRecipientTypes.OrganizationUnit)
|
|
{
|
|
if (notificationRule.RecipientId.IsNullOrWhiteSpace())
|
|
{
|
|
return userIds;
|
|
}
|
|
|
|
var units = await repositoryOrganizationUnit.GetAllChildrenWithParentCodeAsync(notificationRule.RecipientId, null);
|
|
foreach (var unit in units)
|
|
{
|
|
userIds.AddRange(await repositoryOrganizationUnit.GetMemberIdsAsync(unit.Id));
|
|
}
|
|
}
|
|
|
|
return [.. userIds.Distinct()];
|
|
}
|
|
|
|
private static Notification BuildNotification(NotificationRule notificationRule, string identifier, Guid? userId, string message) => new()
|
|
{
|
|
NotificationTypeId = notificationRule.NotificationTypeId,
|
|
NotificationRuleId = notificationRule.Id,
|
|
NotificationChannel = notificationRule.Channel,
|
|
Identifier = identifier,
|
|
UserId = userId,
|
|
Message = message,
|
|
IsSent = IsDeliveredOnCreate(notificationRule.Channel),
|
|
IsRead = false
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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>
|
|
private static bool IsDeliveredOnCreate(string channel)
|
|
=> channel == NotificationChannels.UiActivity;
|
|
}
|