3using System.Globalization;
8using System.Threading.Tasks;
23 private const int DefaultSchemaVersion = 1;
24 private const int MaxPerChannel = 100;
25 private const int MaxTotal = 1000;
29 private readonly List<Expectation> expectations = [];
30 private readonly Dictionary<string, int> channelCounts =
new(StringComparer.OrdinalIgnoreCase);
31 private readonly Queue<NotificationIntent> pendingRoutes =
new();
41 this.intentRouter = IntentRouter;
42 this.filterRegistry = FilterRegistry;
43 this.renderer = Renderer;
58 lock (this.channelCounts)
60 return new Dictionary<string, int>(this.channelCounts);
69 public IDisposable
AddIgnoreFilter(Func<NotificationIntent, NotificationFilterDecision> Predicate)
71 return this.filterRegistry.AddFilter(Predicate);
77 return this.ResolveId(Intent, Intent.
Channel ??
string.Empty, Source);
85 public override async Task
Load(
bool IsResuming, CancellationToken CancellationToken)
87 await base.Load(
IsResuming, CancellationToken);
88 await this.RebuildChannelCountsAsync(CancellationToken);
101 bool shouldPersist = Intent.Presentation is NotificationPresentation.RenderAndStore or
NotificationPresentation.StoreOnly;
102 bool shouldRender = Intent.Presentation is NotificationPresentation.RenderAndStore or
NotificationPresentation.RenderOnly;
104 shouldPersist =
false;
106 shouldRender =
false;
111 this.TrySatisfyExpectations(Record);
114 await this.renderer.RenderAsync(Intent, CancellationToken);
117 "Notification ignored",
118 this.BuildLogProperties(Intent, Source, Record.
Id)
119 .Append(
new KeyValuePair<string, object?>(
"Reason",
"IgnoreStoreOrPresentation"))
125 if (ExistingRecord is not
null && this.AreMergeCompatible(ExistingRecord, Record))
127 ExistingRecord.TimestampCreated = DateTime.UtcNow;
128 ExistingRecord.Title = Record.
Title;
129 ExistingRecord.Body = Record.
Body;
130 ExistingRecord.Action = Record.
Action;
131 ExistingRecord.EntityId = Record.
EntityId;
133 ExistingRecord.ExtrasJson = Record.
ExtrasJson;
134 ExistingRecord.RawPayload = Record.RawPayload ?? ExistingRecord.
RawPayload;
136 ExistingRecord.Source = Record.
Source;
138 ExistingRecord.ReadAt =
null;
139 ExistingRecord.ConsumedAt =
null;
141 ExistingRecord.DeliveredAt = DateTime.UtcNow;
142 ExistingRecord.OccurrenceCount = ExistingRecord.OccurrenceCount > 0 ? ExistingRecord.OccurrenceCount + 1 : 1;
146 "Notification merged",
147 this.BuildLogProperties(Intent, Source, ExistingRecord.
Id)
148 .Append(
new KeyValuePair<string, object?>(
"OccurrenceCount", ExistingRecord.
OccurrenceCount))
150 await this.RaiseAdded(ExistingRecord);
153 await this.renderer.RenderAsync(Intent, CancellationToken);
157 else if (ExistingRecord is not
null)
159 Record.Id = this.ResolveIdWithSalt(Intent, Source);
163 Record.DeliveredAt = DateTime.UtcNow;
166 this.IncrementChannelCount(Record.
Channel);
168 "Notification stored",
169 this.BuildLogProperties(Intent, Source, Record.
Id)
170 .Append(
new KeyValuePair<string, object?>(
"OccurrenceCount", Record.
OccurrenceCount))
172 await this.RaiseAdded(Record);
176 await this.renderer.RenderAsync(Intent, CancellationToken);
185 public async Task
MarkReadAsync(
string Id, CancellationToken CancellationToken)
195 record.ReadAt = DateTime.UtcNow;
197 await this.RaiseAdded(record);
205 public async Task
ConsumeAsync(
string Id, CancellationToken CancellationToken)
215 lock (this.pendingRoutes)
217 this.pendingRoutes.Enqueue(Intent);
229 _ = CancellationToken;
230 await this.MarkConsumedCoreAsync(Id);
233 private async Task<NotificationRecord?> MarkConsumedCoreAsync(
string Id)
240 Record.ConsumedAt = DateTime.UtcNow;
241 Record.OccurrenceCount = 1;
243 await this.RaiseAdded(Record);
256 int Skip = Query.Skip ?? 0;
257 int Limit = Query.Limit ??
int.MaxValue;
259 List<NotificationRecord> Results =
new();
262 IEnumerable<NotificationRecord> Ordered = FromDb.OrderByDescending(Record => Record.
TimestampCreated);
266 if (!filter.Matches(Record))
278 if (Results.Count >= Limit)
289 public async Task
PruneAsync(CancellationToken CancellationToken)
291 List<NotificationRecord> All =
new();
292 Dictionary<string, List<NotificationRecord>> PerChannel =
new(StringComparer.OrdinalIgnoreCase);
298 if (!PerChannel.TryGetValue(Record.
Channel, out List<NotificationRecord>? List))
300 List =
new List<NotificationRecord>();
301 PerChannel[Record.
Channel] = List;
306 List<NotificationRecord> ToDelete =
new();
308 foreach (KeyValuePair<
string, List<NotificationRecord>> Pair
in PerChannel)
310 List<NotificationRecord> Ordered = Pair.Value.OrderBy(R => R.TimestampCreated).ToList();
311 int Excess = Ordered.Count - MaxPerChannel;
313 ToDelete.AddRange(Ordered.Take(Excess));
316 if (All.Count - ToDelete.Count > MaxTotal)
318 List<NotificationRecord> Ordered = All.OrderBy(R => R.TimestampCreated).ToList();
319 int Need = (All.Count - MaxTotal) - ToDelete.Count;
321 ToDelete.AddRange(Ordered.Take(Need));
327 this.DecrementChannelCount(Record.
Channel);
336 public async Task
DeleteAsync(IEnumerable<string> Ids, CancellationToken CancellationToken)
341 foreach (
string Id
in Ids)
348 this.DecrementChannelCount(Record.
Channel);
349 await this.RaiseAdded(Record);
359 lock (this.pendingRoutes)
361 if (this.pendingRoutes.Count > 0)
362 intent = this.pendingRoutes.Dequeue();
368 await this.intentRouter.RouteAsync(intent,
true, CancellationToken);
374 string Channel = Intent.Channel ??
string.Empty;
375 string ActionString = Intent.
Action.ToString();
376 string ExtrasJson = JsonSerializer.Serialize(Intent.
Extras);
378 string Id = this.ResolveId(Intent, Channel, Source);
384 Title = Intent.
Title,
386 Action = ActionString,
389 ExtrasJson = ExtrasJson,
390 RawPayload = RawPayload,
391 SchemaVersion = Intent.Version > 0 ? Intent.Version : DefaultSchemaVersion,
392 TimestampCreated = DateTime.UtcNow,
395 Presentation = Intent.Presentation
399 private string ResolveId(NotificationIntent Intent,
string Channel,
NotificationSource Source)
401 StringBuilder builder =
new();
402 string correlation = Intent.CorrelationId ??
string.Empty;
404 builder.Append(Channel);
406 builder.Append(Intent.Action.ToString());
408 builder.Append(Intent.EntityId ??
string.Empty);
410 builder.Append(correlation);
412 builder.Append(Intent.Title ??
string.Empty);
414 builder.Append(Intent.Body ??
string.Empty);
416 builder.Append(this.SerializeExtras(Intent.Extras));
418 builder.Append(Source.ToString());
420 byte[] Input = Encoding.UTF8.GetBytes(builder.ToString());
421 byte[] Hash = SHA256.HashData(Input);
422 return Convert.ToHexString(Hash);
425 private async Task<NotificationRecord?> LoadByIdAsync(
string? Id)
434 catch (KeyNotFoundException)
440 private async Task<NotificationRecord?> LoadByCorrelationdAsync(
string CorrelationId)
442 if (CorrelationId is
null)
447 return await
Database.FindFirstDeleteRest<NotificationRecord>(
new FilterFieldEqualTo(nameof(NotificationRecord.CorrelationId), CorrelationId));
449 catch (KeyNotFoundException)
455 private async Task RaiseAdded(NotificationRecord Record)
458 if (Handler is not
null)
459 await Handler.Raise(
this,
new NotificationRecordEventArgs(Record));
461 this.TrySatisfyExpectations(Record);
471 public async Task<NotificationRecord?>
WaitForAsync(Func<NotificationRecord, bool> Predicate, TimeSpan Timeout, CancellationToken CancellationToken)
476 if (Predicate(Record))
480 TaskCompletionSource<NotificationRecord?> Tcs =
new(TaskCreationOptions.RunContinuationsAsynchronously);
481 Expectation expectation =
new(Predicate, DateTime.UtcNow.Add(Timeout), Tcs,
false);
483 lock (this.expectations)
485 this.expectations.Add(expectation);
488 using CancellationTokenRegistration reg = CancellationToken.Register(() => Tcs.TrySetCanceled(CancellationToken));
489 Task delayTask = Task.Delay(Timeout, CancellationToken);
490 Task finished = await Task.WhenAny(Tcs.Task, delayTask);
492 lock (this.expectations)
494 this.expectations.Remove(expectation);
497 if (finished == Tcs.Task)
498 return await Tcs.Task;
509 public Task
ExpectAsync(Func<NotificationRecord, bool> Predicate, TimeSpan Timeout, CancellationToken CancellationToken)
511 Expectation expectation =
new(Predicate, DateTime.UtcNow.Add(Timeout),
null,
true);
513 lock (this.expectations)
515 this.expectations.Add(expectation);
518 _ = Task.Delay(Timeout, CancellationToken).ContinueWith(
_ =>
520 lock (this.expectations)
522 this.expectations.Remove(expectation);
524 }, CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);
526 return Task.CompletedTask;
531 List<Expectation> Matches =
new();
533 lock (this.expectations)
535 DateTime now = DateTime.UtcNow;
536 for (
int i = this.expectations.Count - 1; i >= 0; i--)
538 Expectation exp = this.expectations[i];
539 if (exp.ExpiresAt < now)
541 this.expectations.RemoveAt(i);
545 if (exp.Predicate(Record))
547 this.expectations.RemoveAt(i);
553 foreach (Expectation exp
in Matches)
555 if (exp.Source is not
null)
556 exp.Source.TrySetResult(Record);
558 if (exp.RouteOnMatch)
563 private NotificationIntent ToIntent(NotificationRecord Record)
565 Dictionary<string, string>? extras = JsonSerializer.Deserialize<Dictionary<string, string>>(Record.ExtrasJson);
567 return new NotificationIntent
570 EntityId = Record.EntityId,
571 Channel = Record.Channel,
572 Title = Record.Title,
574 Extras = extras ??
new Dictionary<string, string>(),
575 Version = Record.SchemaVersion,
576 Presentation = Record.Presentation
580 private sealed record Expectation(Func<NotificationRecord, bool> Predicate, DateTime ExpiresAt, TaskCompletionSource<NotificationRecord?>? Source,
bool RouteOnMatch);
582 private void IncrementChannelCount(
string Channel)
584 lock (this.channelCounts)
586 this.channelCounts.TryGetValue(Channel, out
int count);
587 this.channelCounts[Channel] = count + 1;
591 private void DecrementChannelCount(
string Channel)
593 lock (this.channelCounts)
595 if (this.channelCounts.TryGetValue(Channel, out
int count) && count > 0)
596 this.channelCounts[Channel] = count - 1;
600 private async Task RebuildChannelCountsAsync(CancellationToken CancellationToken)
602 lock (this.channelCounts)
604 this.channelCounts.Clear();
607 IEnumerable<NotificationRecord> records = await
Database.
Find<NotificationRecord>(nameof(NotificationRecord.TimestampCreated));
608 foreach (NotificationRecord record
in records)
610 CancellationToken.ThrowIfCancellationRequested();
611 this.IncrementChannelCount(record.Channel);
615 private string SerializeExtras(Dictionary<string, string> extras)
617 if (extras is
null || extras.Count == 0)
620 StringBuilder builder =
new StringBuilder();
621 foreach (KeyValuePair<string, string> pair
in extras.OrderBy(k => k.Key, StringComparer.Ordinal))
623 builder.Append(pair.Key);
625 builder.Append(pair.Value);
629 return builder.ToString();
632 private IEnumerable<KeyValuePair<string, object?>> BuildLogProperties(NotificationIntent Intent,
NotificationSource Source,
string notificationId)
634 yield
return new KeyValuePair<string, object?>(
"NotificationId", notificationId);
635 yield
return new KeyValuePair<string, object?>(
"Channel", Intent.Channel ??
string.Empty);
636 yield
return new KeyValuePair<string, object?>(
"Action", Intent.Action.ToString());
637 yield
return new KeyValuePair<string, object?>(
"EntityId", Intent.EntityId ??
string.Empty);
638 yield
return new KeyValuePair<string, object?>(
"CorrelationId", Intent.CorrelationId ??
string.Empty);
639 yield
return new KeyValuePair<string, object?>(
"Presentation", Intent.Presentation.ToString());
640 yield
return new KeyValuePair<string, object?>(
"Source", Source.ToString());
643 private bool AreMergeCompatible(NotificationRecord existing, NotificationRecord incoming)
645 return string.Equals(existing.Action, incoming.Action, StringComparison.Ordinal) &&
646 string.Equals(existing.EntityId, incoming.EntityId, StringComparison.Ordinal) &&
647 string.Equals(existing.CorrelationId ??
string.Empty, incoming.CorrelationId ??
string.Empty, StringComparison.Ordinal);
650 private string ResolveIdWithSalt(NotificationIntent intent,
NotificationSource source)
652 string salt = Guid.NewGuid().ToString(
"N");
653 StringBuilder builder =
new StringBuilder();
654 builder.Append(intent.Channel ??
string.Empty);
656 builder.Append(intent.Action.ToString());
658 builder.Append(intent.EntityId ??
string.Empty);
660 builder.Append(intent.CorrelationId ??
string.Empty);
662 builder.Append(intent.Title ??
string.Empty);
664 builder.Append(intent.Body ??
string.Empty);
666 builder.Append(this.SerializeExtras(intent.Extras));
668 builder.Append(source.ToString());
670 builder.Append(salt);
672 byte[] input = Encoding.UTF8.GetBytes(builder.ToString());
673 byte[] hash = SHA256.HashData(input);
674 return Convert.ToHexString(hash);
678 private sealed
class Filter
680 private readonly HashSet<string>? channels;
681 private readonly HashSet<NotificationState>? states;
683 public Filter(IReadOnlyList<string>? Channels, IReadOnlyList<NotificationState>? States)
685 if (Channels is not
null && Channels.Count > 0)
686 this.channels =
new HashSet<string>(Channels, StringComparer.OrdinalIgnoreCase);
688 if (States is not
null && States.Count > 0)
689 this.states =
new HashSet<NotificationState>(States);
692 public bool Matches(NotificationRecord Record)
694 if (this.channels is not
null && !this.channels.Contains(Record.Channel))
697 if (this.states is not
null && !this.states.Contains(Record.State))
bool IsResuming
If App is resuming service.
Represents a filter decision for notification handling.
bool IgnoreStore
Gets a value indicating whether storage should be suppressed.
bool IgnoreRender
Gets a value indicating whether rendering should be suppressed.
Platform-neutral intent describing how to route a notification.
string? EntityId
Gets or sets an entity identifier associated with the action.
string? CorrelationId
Gets or sets an optional correlation identifier.
string? Body
Gets or sets the message body to display.
string Title
Gets or sets the title to display.
NotificationAction Action
Gets or sets the intended action.
string? Channel
Gets or sets the push channel identifier.
Dictionary< string, string > Extras
Gets or sets extra data used for routing.
Query options for retrieving notifications.
IReadOnlyList< NotificationState >? States
Gets states to include. Empty/null means all.
IReadOnlyList< string >? Channels
Gets channels to include. Empty/null means all.
Persisted notification record.
string? RawPayload
Gets or sets the raw payload received from the transport.
NotificationSource Source
Gets or sets the notification source.
string? CorrelationId
Gets or sets the correlation identifier used for deduplication.
int SchemaVersion
Gets or sets the schema version used when parsing the payload.
string Channel
Gets or sets the channel identifier.
NotificationPresentation Presentation
Gets or sets the presentation preference.
string? EntityId
Gets or sets the entity identifier associated with the notification.
DateTime TimestampCreated
Gets or sets the timestamp when the notification was created.
string Id
Gets or sets the stable notification identifier.
int OccurrenceCount
Gets or sets how many times this notification intent has been observed.
NotificationState State
Gets or sets the notification state.
string? Body
Gets or sets the notification body.
string Title
Gets or sets the notification title.
string Action
Gets or sets the action to execute when consumed.
string ExtrasJson
Gets or sets serialized extras payload (JSON).
Redesigned notification service with Waher persistence.
IDisposable AddIgnoreFilter(Func< NotificationIntent, NotificationFilterDecision > Predicate)
Adds a runtime ignore filter. Dispose the handle to remove it.
async Task< IReadOnlyList< NotificationRecord > > GetAsync(NotificationQuery Query, CancellationToken CancellationToken)
Retrieves notifications that match the query.
async Task ProcessPendingAsync(CancellationToken CancellationToken)
Attempts to route any pending deferred intents.
async Task< NotificationRecord?> WaitForAsync(Func< NotificationRecord, bool > Predicate, TimeSpan Timeout, CancellationToken CancellationToken)
Awaits a notification matching the predicate within a timeout.
string ComputeId(NotificationIntent Intent, NotificationSource Source)
Computes the stable identifier for an intent using the same logic as storage. Stable identifier.
EventHandlerAsync< NotificationRecordEventArgs >? OnNotificationAdded
Event raised when a notification is added or updated.
override async Task Load(bool IsResuming, CancellationToken CancellationToken)
Loads the service.
async Task PruneAsync(CancellationToken CancellationToken)
Applies retention pruning to stored notifications.
async Task MarkConsumedAsync(string Id, CancellationToken CancellationToken)
Marks a notification as consumed without routing it.
IReadOnlyDictionary< string, int > ChannelCounts
Current counts per channel.
async Task ConsumeAsync(string Id, CancellationToken CancellationToken)
Marks a notification as consumed and updates state.
async Task DeleteAsync(IEnumerable< string > Ids, CancellationToken CancellationToken)
Deletes notifications by identifier.
async Task MarkReadAsync(string Id, CancellationToken CancellationToken)
Marks a notification as read without consuming it.
async Task AddAsync(NotificationIntent Intent, NotificationSource Source, string? RawPayload, CancellationToken CancellationToken)
Adds or updates a notification based on the provided intent.
NotificationServiceV2(INotificationIntentRouter IntentRouter, INotificationFilterRegistry FilterRegistry, INotificationRenderer Renderer)
Initializes a new instance of the NotificationServiceV2 class.
Task ExpectAsync(Func< NotificationRecord, bool > Predicate, TimeSpan Timeout, CancellationToken CancellationToken)
Registers an expectation that will auto-route matching notifications when they arrive.
Base class that references services in the app.
static ILogService LogService
Log service.
Static interface for database persistence. In order to work, a database provider has to be assigned t...
static async Task Update(object Object)
Updates an object in the database.
static async Task Delete(object Object)
Deletes an object in the database.
static Task< IEnumerable< object > > Find(string Collection, params string[] SortOrder)
Finds objects in a given collection.
static async Task Insert(object Object)
Inserts an object into the default collection of the database.
This filter selects objects that have a named field equal to a given value.
Base class for all filter classes.
Manages runtime notification filters (ignore rules).
Routes notification intents to navigation targets.
Interface for the redesigned notification service.
Renders local notifications on the platform.
NotificationState
Notification lifecycle state.
NotificationRouteResult
Possible outcomes when routing a notification intent.
NotificationAction
Actions that can be routed from notifications.
NotificationPresentation
Presentation preference for a notification intent.
NotificationSource
Describes the source producing a notification.
Action
The Action field indicates the action performed by the Reporting-MTA as a result of its attempt to de...