Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
806ce0a
Add notification channels: table, API and encrypted settings
hamzahalq Oct 12, 2026
43a337e
Send subscription notifications through channels
hamzahalq Oct 12, 2026
0786027
Add Email, Teams and Slack channel types and a test button
hamzahalq Oct 12, 2026
04e53cb
Notify for an exchange whose subscription was switched off meanwhile
hamzahalq Oct 12, 2026
df884ce
Send retry, pause and quiet alerts through channels
hamzahalq Oct 12, 2026
7fb0ec7
Drop a kept subscription secret when its adapter changes
hamzahalq Oct 12, 2026
249031e
Add notification channels to the UI
hamzahalq Oct 13, 2026
5f0c93a
Add notification channels to bitween apply
hamzahalq Oct 13, 2026
a150e9b
Show held-back channel sends as held back
hamzahalq Oct 13, 2026
9580ac8
Document notification channels
hamzahalq Oct 13, 2026
6d6927d
Count notification channels where connections and retiring adapters a…
hamzahalq Oct 13, 2026
bed7540
Step aside from the alert move only when another copy finished it
hamzahalq Oct 13, 2026
ff4d49c
Name notification changes in a subscription's versions
hamzahalq Oct 13, 2026
67bec2e
Give up on a silent mail server after 30 seconds
hamzahalq Oct 13, 2026
18b523b
Show errors without their stack trace in channel messages
hamzahalq Oct 13, 2026
f0bc18e
Keep alerts out of a channel's quiet window and bound its lookup
hamzahalq Oct 13, 2026
5619a50
Match secret names ignoring case when hiding and keeping them
hamzahalq Oct 13, 2026
f8a1984
Hold inline retry policies to the alert rules when a subscription is …
hamzahalq Oct 13, 2026
c81420a
Move only alerts that sent, a page of inline policies at a time
hamzahalq Oct 13, 2026
12b3ba1
Leave out empty facts and escape the Slack banner
hamzahalq Oct 13, 2026
31c65d2
Refetch channel usage and sends whenever they are opened
hamzahalq Oct 13, 2026
d423c38
Keep incomplete or repeated notification rows from being saved
hamzahalq Oct 13, 2026
b2b7054
Refuse to save a retry group that was removed meanwhile
hamzahalq Oct 13, 2026
2ac98d2
Report an unexpected customRetryPolicy shape in bitween plan
hamzahalq Oct 13, 2026
4a37153
Open Settings on the notification channels for roles without Settings
hamzahalq Oct 13, 2026
a70331c
Correct what blocks deleting a channel in the API reference
hamzahalq Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ One .NET 10 service hosts everything: the REST API, the partner-facing gateway e
- **One pipeline.** Every message becomes an *exchange* that runs through the same stages: filter, map, deliver, route the response.
- **Adapters for each stage.** Built-in adapters cover HTTP, S3, Azure Blob, SFTP/FTP, POP3 and SMTP, plus a visual rules-based mapper for JSON and XML. Anything else can be a custom adapter, written in .NET, Python, JavaScript or TypeScript, that runs out of process. The `bitween` CLI writes, checks and publishes them, and Python, JavaScript and TypeScript adapters can also be written in the admin UI.
- **Long-lived connections.** Data sources hold connections to RabbitMQ and Amazon SQS brokers and to PostgreSQL, MySQL, SQL Server and Oracle databases, so deliveries can publish messages or run SQL.
- **Reliability built in.** Retry policies match failures and retry them with a delay and a shared budget. Every exchange's retries form a chain you can follow. Exhausted budgets raise alerts, and notifiers report results.
- **Reliability built in.** Retry policies match failures and retry them with a delay and a shared budget. Every exchange's retries form a chain you can follow. Exhausted budgets raise alerts, and subscriptions report their results, both through notification channels: Email, Microsoft Teams, Slack or any adapter, each set up once.
- **Operable.** Queue health straight from RabbitMQ, data source health, schedule health, run history, receive attempts, a dashboard and an audit trail of every configuration change.
- **Administered in the browser.** Roles with fine-grained permissions, Microsoft sign-in, and settings and branding that change at runtime.

Expand Down Expand Up @@ -41,7 +41,7 @@ flowchart LR
X -->|work group queue| P --> R
P -->|delivery| OUT[Partner systems]
P -->|publish or SQL| EXT
R -->|notifiers and alerts| N[Handler adapters]
R -->|notifications and alerts| N[Notification channels]
X -. files .-> S[(Object storage)]
R -. rows .-> DB[(Bitween database)]
```
Expand Down Expand Up @@ -115,7 +115,7 @@ The database schema is created on first start. A new database refuses to start w
| [Data sources](docs/data-sources.md) | Long-lived connections: enabling them, placement, health and resource limits |
| [External brokers](docs/external-brokers.md) | Reading from and publishing to a customer's RabbitMQ or Amazon SQS |
| [Databases](docs/databases.md) | PostgreSQL, MySQL, SQL Server and Oracle: statements, polling and writing |
| [Retries and alerts](docs/retries-and-alerts.md) | Manual and bulk retry, retry chains, retry policies, budgets, alerts and notifiers |
| [Retries and alerts](docs/retries-and-alerts.md) | Manual and bulk retry, retry chains, retry policies, budgets, alerts, notification channels and notifications |
| [Scheduling](docs/scheduling.md) | Schedules, jobs, run history and schedule health |
| [Security](docs/security.md) | Sign-in, tokens, roles and permissions, partner keys, hardening, audit |
| [Configuration](docs/configuration.md) | Every configuration key, runtime setting and Helm value |
Expand Down
2 changes: 2 additions & 0 deletions SW.Bitween.Api/Data/AuditPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public static class AuditPolicy
typeof(RetryPolicy),
typeof(RetryAlertOverride),
typeof(Notifier),
typeof(NotificationChannel),
typeof(GlobalAdapterValuesSet),
// Who added a connection, changed its client, or authorized it. Token renewals bypass the
// change tracker, so the hourly refresh of a token isn't an entry.
Expand Down Expand Up @@ -93,6 +94,7 @@ public static class AuditPolicy
],
[typeof(Partner)] = [nameof(Partner.AdapterProperties)],
[typeof(Notifier)] = [nameof(Notifier.HandlerProperties)],
[typeof(NotificationChannel)] = [nameof(NotificationChannel.HandlerProperties)],
[typeof(RetryPolicy)] = [nameof(RetryPolicy.AlertHandlerProperties)],
[typeof(RetryAlertOverride)] = [nameof(RetryAlertOverride.AlertHandlerProperties)],

Expand Down
13 changes: 13 additions & 0 deletions SW.Bitween.Api/Data/BitweenDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.OnDelete(DeleteBehavior.SetNull);
b.Property(p => p.CustomRetryPolicy).StoreAsJson();
b.Property(p => p.ExpectTraffic).StoreAsJson();
b.Property(p => p.Notifications).StoreAsJson();
b.Property(p => p.MatchExpression).HasMatchExpressionConversion();
});

Expand Down Expand Up @@ -577,6 +578,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.HasIndex(p => p.XchangeId);
// The quiet window looks up a notifier's recent sends.
b.HasIndex(p => new { p.NotifierId, p.FinishedOn });
// And a channel's.
b.HasIndex(p => new { p.ChannelId, p.FinishedOn });
});

modelBuilder.Entity<XchangeDelivery>(b =>
Expand Down Expand Up @@ -661,6 +664,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.Property(p => p.RunOnSubscriptions).IsSeparatorDelimited();
});

modelBuilder.Entity<NotificationChannel>(b =>
{
b.ToTable("NotificationChannels");
b.Property(p => p.Id).ValueGeneratedOnAdd();
b.Property(p => p.Name).HasMaxLength(100).IsRequired();
b.HasIndex(p => p.Name).IsUnique();
b.Property(p => p.HandlerId).HasMaxLength(200).IsUnicode(false).IsRequired();
b.Property(p => p.HandlerProperties).StoreAsJson().Encrypted();
});

modelBuilder.Entity<Account>(b =>
{
b.ToTable("Accounts");
Expand Down
32 changes: 32 additions & 0 deletions SW.Bitween.Api/Domain/NotificationChannel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain;

/// <summary>
/// Somewhere Bitween can send a notification: a handler adapter and everything it needs to deliver,
/// such as the recipients or a webhook url. Set up once in Settings, then picked by name wherever
/// something is worth telling someone about.
/// </summary>
/// <remarks>
/// A channel is complete. The places that use one choose only <em>when</em> to send, never how, so
/// what the Settings page shows for a channel is exactly what every use of it sends. Different
/// recipients are a second channel.
/// </remarks>
public class NotificationChannel : BaseEntity, IAudited
{
public string Name { get; set; }

/// <summary>Any handler adapter, built in or custom.</summary>
public string HandlerId { get; set; }

/// <summary>The adapter's settings. Encrypted at rest, since they can hold passwords and keys.</summary>
public IReadOnlyDictionary<string, string> HandlerProperties { get; set; } =
new Dictionary<string, string>();

public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
public DateTime? ModifiedOn { get; set; }
public string ModifiedBy { get; set; }
}
10 changes: 8 additions & 2 deletions SW.Bitween.Api/Domain/RetryAlertOverride.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,15 @@ public class RetryAlertOverride
/// </summary>
public RetryAlertMode AlertMode { get; set; }

/// <summary>Adapter that delivers the alert. Required when <see cref="AlertMode"/> is Send.</summary>
/// <summary>Channel that delivers the alert. Required when <see cref="AlertMode"/> is Send.</summary>
public int? AlertChannelId { get; set; }

/// <summary>
/// Retired: alerts go through <see cref="AlertChannelId"/>. Only the one-time move onto a
/// channel reads it, and clears it.
/// </summary>
public string AlertHandlerId { get; set; }

/// <summary>That adapter's own settings - api key, recipients, subject.</summary>
/// <summary>Retired along with <see cref="AlertHandlerId"/>.</summary>
public IReadOnlyDictionary<string, string> AlertHandlerProperties { get; set; }
}
10 changes: 8 additions & 2 deletions SW.Bitween.Api/Domain/RetryPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@ public class RetryPolicy : BaseEntity, IAudited, IRetryPolicy
public List<RetryGroup> Groups { get; set; } = [];

/// <summary>
/// Default destination for "retry budget exhausted" alerts, used by every group that does not
/// Default channel for "retry budget exhausted" alerts, used by every group that does not
/// override it. Null means no alert unless a group or a subscription+group override defines one.
/// </summary>
public int? AlertChannelId { get; set; }

/// <summary>
/// Retired: alerts go through <see cref="AlertChannelId"/>. Only the one-time move onto a
/// channel reads it, and clears it.
/// </summary>
public string AlertHandlerId { get; set; }

/// <summary>That adapter's own settings - api key, recipients, subject.</summary>
/// <summary>Retired along with <see cref="AlertHandlerId"/>.</summary>
public IReadOnlyDictionary<string, string> AlertHandlerProperties { get; set; }
public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
Expand Down
9 changes: 9 additions & 0 deletions SW.Bitween.Api/Domain/Subscription/Subscription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using SW.PrimitiveTypes;
using System;
using System.Collections.Generic;
using System.Linq;


namespace SW.Bitween.Domain;
Expand Down Expand Up @@ -95,6 +96,14 @@ private Subscription(string name, int documentId, SubscriptionType type, int? pa
/// </summary>
public DateTime? QuietSince { get; private set; }

/// <summary>Which finished exchanges are reported, and through which notification channel.</summary>
public List<SubscriptionNotification> Notifications { get; private set; } = [];

public void SetNotifications(IEnumerable<SubscriptionNotification> notifications)
{
Notifications = notifications?.ToList() ?? [];
}

public void SetExpectTraffic(ExpectTraffic expectTraffic)
{
ExpectTraffic = expectTraffic;
Expand Down
27 changes: 20 additions & 7 deletions SW.Bitween.Api/Domain/XchangeNotification.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,29 +25,42 @@ public XchangeNotification(string xchangeId, int? notifierId, string notifierNam
/// <see cref="NotifierId"/> because the alert is configured on the retry policy rather than
/// on a notifier - which is also how the send is recognised as already done on a redelivery.
/// </summary>
public static XchangeNotification ForRetryBudgetAlert(string xchangeId, string exception = null) =>
new(xchangeId, null, RetryBudgetAlertName, exception);
public static XchangeNotification ForRetryBudgetAlert(string xchangeId, int channelId,
string exception = null) =>
new(xchangeId, null, RetryBudgetAlertName, exception) { ChannelId = channelId };

public const string AutoPauseAlertName = "Auto-pause alert";

/// <summary>The alert that the subscription this exchange belongs to was paused by its failure.</summary>
public static XchangeNotification ForAutoPauseAlert(string xchangeId, string exception = null) =>
new(xchangeId, null, AutoPauseAlertName, exception);
/// <summary>
/// The alert that the subscription this exchange belongs to was paused by its failure. One row
/// per channel told, so a redelivery skips the channels that already heard.
/// </summary>
public static XchangeNotification ForAutoPauseAlert(string xchangeId, int channelId,
string exception = null) =>
new(xchangeId, null, AutoPauseAlertName, exception) { ChannelId = channelId };

/// <summary>Logs a send a subscription's own notifications made through a channel.</summary>
public static XchangeNotification ForChannel(string xchangeId, int channelId, string channelName,
string exception = null) =>
new(xchangeId, null, channelName, exception) { ChannelId = channelId };


public string XchangeId { get; private set; }
public bool Success { get; set; }

/// <summary>The notifier that produced this row, or <c>null</c> for a retry-budget alert.</summary>
/// <summary>The notifier that produced this row, or <c>null</c> for an alert or a channel send.</summary>
public int? NotifierId { get; set; }

/// <summary>The channel the send went through. Null on rows written by a legacy notifier.</summary>
public int? ChannelId { get; private set; }
public string NotifierName { get; set; }


public string Exception { get; private set; }
public DateTime FinishedOn { get; private set; }

/// <summary>
/// Not sent: the notifier had already sent for this subscription and outcome within its
/// Not sent: the notifier or channel had already sent for this subscription and outcome within its
/// quiet window. Counted into the next notification that is sent.
/// </summary>
public bool Suppressed { get; set; }
Expand Down
1 change: 1 addition & 0 deletions SW.Bitween.Api/Interfaces/IInfolinkCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public interface IInfolinkCache
public Task<Subscription[]> ListSubscriptionsByDocumentAsync(int documentId);
public Task<BusGatewayRoute[]> ListBusGatewayRoutesByDocumentAsync(int documentId);
public Task<Notifier[]> ListNotifiersAsync();
public Task<NotificationChannel> NotificationChannelByIdAsync(int channelId);

public Task<Subscription> SubscriptionByIdAsync(int subscriptionId);
public Task<Document> DocumentByIdAsync(int documentId);
Expand Down
5 changes: 3 additions & 2 deletions SW.Bitween.Api/Resources/Adapters/RetiringAdapters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ namespace SW.Bitween.Resources.Adapters;
/// <para>
/// One-way on purpose: past that point there is no picking it again. Which is why the question of
/// what counts as "in use" is answered generously - an inactive subscription still holds its
/// settings and can be switched back on, so it counts, and so does a notifier or a retry alert
/// naming a handler. Withholding an adapter a subscription is still saved with would leave that
/// settings and can be switched back on, so it counts, and so does a notification channel, a
/// notifier or a retry alert naming a handler. Withholding an adapter a subscription is still saved with would leave that
/// subscription's own adapter missing from the list its picker draws from, which reads as no
/// adapter chosen at all.
/// </para>
Expand Down Expand Up @@ -82,6 +82,7 @@ private static Task<bool> ReceiverInUse(BitweenDbContext db, string id) =>

private static async Task<bool> HandlerInUse(BitweenDbContext db, string id) =>
await db.Set<Subscription>().AnyAsync(s => s.HandlerId != null && s.HandlerId.ToLower() == id)
|| await db.Set<NotificationChannel>().AnyAsync(c => c.HandlerId.ToLower() == id)
|| await db.Set<Notifier>().AnyAsync(n => n.HandlerId != null && n.HandlerId.ToLower() == id)
|| await db.Set<RetryPolicy>().AnyAsync(p => p.AlertHandlerId != null && p.AlertHandlerId.ToLower() == id)
|| await db.Set<RetryAlertOverride>().AnyAsync(o => o.AlertHandlerId != null && o.AlertHandlerId.ToLower() == id);
Expand Down
2 changes: 2 additions & 0 deletions SW.Bitween.Api/Resources/Connections/ConnectionRows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ bool Uses(IEnumerable<IReadOnlyDictionary<string, string>> bags) =>
foreach (var s in db.Set<Domain.Subscription>().AsEnumerable())
if (Uses([s.HandlerProperties, s.MapperProperties, s.ReceiverProperties, s.ValidatorProperties]))
yield return $"subscription '{s.Name}'";
foreach (var c in db.Set<Domain.NotificationChannel>().AsEnumerable())
if (Uses([c.HandlerProperties])) yield return $"notification channel '{c.Name}'";
foreach (var n in db.Set<Domain.Notifier>().AsEnumerable())
if (Uses([n.HandlerProperties])) yield return $"notifier '{n.Name}'";
foreach (var p in db.Set<Domain.RetryPolicy>().AsEnumerable())
Expand Down
Loading
Loading