Add tests and implement timeline ingestion options with NATS and Redis subscribers
- Introduced `BinaryReachabilityLifterTests` to validate binary lifting functionality. - Created `PackRunWorkerOptions` for configuring worker paths and execution persistence. - Added `TimelineIngestionOptions` for configuring NATS and Redis ingestion transports. - Implemented `NatsTimelineEventSubscriber` for subscribing to NATS events. - Developed `RedisTimelineEventSubscriber` for reading from Redis Streams. - Added `TimelineEnvelopeParser` to normalize incoming event envelopes. - Created unit tests for `TimelineEnvelopeParser` to ensure correct field mapping. - Implemented `TimelineAuthorizationAuditSink` for logging authorization outcomes.
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
# StellaOps.Notify.Storage.Mongo — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement Mongo persistence (rules, channels, deliveries, digests, locks, audit) per `docs/modules/notify/ARCHITECTURE.md`.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/notify/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
@@ -1,31 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
public sealed class NotifyAuditEntryDocument
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; init; }
|
||||
|
||||
[BsonElement("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[BsonElement("actor")]
|
||||
public required string Actor { get; init; }
|
||||
|
||||
[BsonElement("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
[BsonElement("entityId")]
|
||||
public string EntityId { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("entityType")]
|
||||
public string EntityType { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[BsonElement("payload")]
|
||||
public BsonDocument Payload { get; init; } = new();
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
public sealed class NotifyDigestDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[BsonElement("actionKey")]
|
||||
public required string ActionKey { get; init; }
|
||||
|
||||
[BsonElement("window")]
|
||||
public required string Window { get; init; }
|
||||
|
||||
[BsonElement("openedAt")]
|
||||
public required DateTimeOffset OpenedAt { get; init; }
|
||||
|
||||
[BsonElement("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[BsonElement("items")]
|
||||
public List<NotifyDigestItemDocument> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class NotifyDigestItemDocument
|
||||
{
|
||||
[BsonElement("eventId")]
|
||||
public string EventId { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("scope")]
|
||||
public Dictionary<string, string> Scope { get; init; } = new();
|
||||
|
||||
[BsonElement("delta")]
|
||||
public Dictionary<string, int> Delta { get; init; } = new();
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
public sealed class NotifyLockDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[BsonElement("resource")]
|
||||
public required string Resource { get; init; }
|
||||
|
||||
[BsonElement("acquiredAt")]
|
||||
public required DateTimeOffset AcquiredAt { get; init; }
|
||||
|
||||
[BsonElement("expiresAt")]
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
[BsonElement("owner")]
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
public sealed class PackApprovalDocument
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; init; }
|
||||
|
||||
[BsonElement("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[BsonElement("eventId")]
|
||||
public required Guid EventId { get; init; }
|
||||
|
||||
[BsonElement("packId")]
|
||||
public required string PackId { get; init; }
|
||||
|
||||
[BsonElement("kind")]
|
||||
public required string Kind { get; init; }
|
||||
|
||||
[BsonElement("decision")]
|
||||
public required string Decision { get; init; }
|
||||
|
||||
[BsonElement("actor")]
|
||||
public required string Actor { get; init; }
|
||||
|
||||
[BsonElement("issuedAt")]
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
[BsonElement("policyId")]
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
[BsonElement("policyVersion")]
|
||||
public string? PolicyVersion { get; init; }
|
||||
|
||||
[BsonElement("resumeToken")]
|
||||
public string? ResumeToken { get; init; }
|
||||
|
||||
[BsonElement("summary")]
|
||||
public string? Summary { get; init; }
|
||||
|
||||
[BsonElement("labels")]
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
internal sealed class NotifyMongoContext
|
||||
{
|
||||
public NotifyMongoContext(IOptions<NotifyMongoOptions> options, ILogger<NotifyMongoContext> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
var value = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value.ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Notify Mongo connection string is not configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value.Database))
|
||||
{
|
||||
throw new InvalidOperationException("Notify Mongo database name is not configured.");
|
||||
}
|
||||
|
||||
Client = new MongoClient(value.ConnectionString);
|
||||
var settings = new MongoDatabaseSettings();
|
||||
if (value.UseMajorityReadConcern)
|
||||
{
|
||||
settings.ReadConcern = ReadConcern.Majority;
|
||||
}
|
||||
if (value.UseMajorityWriteConcern)
|
||||
{
|
||||
settings.WriteConcern = WriteConcern.WMajority;
|
||||
}
|
||||
|
||||
Database = Client.GetDatabase(value.Database, settings);
|
||||
Options = value;
|
||||
}
|
||||
|
||||
public MongoClient Client { get; }
|
||||
|
||||
public IMongoDatabase Database { get; }
|
||||
|
||||
public NotifyMongoOptions Options { get; }
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
internal interface INotifyMongoInitializer
|
||||
{
|
||||
Task EnsureIndexesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class NotifyMongoInitializer : INotifyMongoInitializer
|
||||
{
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly NotifyMongoMigrationRunner _migrationRunner;
|
||||
private readonly ILogger<NotifyMongoInitializer> _logger;
|
||||
|
||||
public NotifyMongoInitializer(
|
||||
NotifyMongoContext context,
|
||||
NotifyMongoMigrationRunner migrationRunner,
|
||||
ILogger<NotifyMongoInitializer> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_migrationRunner = migrationRunner ?? throw new ArgumentNullException(nameof(migrationRunner));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task EnsureIndexesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Ensuring Notify Mongo migrations are applied for database {Database}.", _context.Options.Database);
|
||||
await _migrationRunner.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal sealed class EnsureNotifyCollectionsMigration : INotifyMongoMigration
|
||||
{
|
||||
private readonly ILogger<EnsureNotifyCollectionsMigration> _logger;
|
||||
|
||||
public EnsureNotifyCollectionsMigration(ILogger<EnsureNotifyCollectionsMigration> logger)
|
||||
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public string Id => "20251019_notify_collections_v1";
|
||||
|
||||
public async ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var requiredCollections = new[]
|
||||
{
|
||||
context.Options.RulesCollection,
|
||||
context.Options.ChannelsCollection,
|
||||
context.Options.TemplatesCollection,
|
||||
context.Options.DeliveriesCollection,
|
||||
context.Options.DigestsCollection,
|
||||
context.Options.LocksCollection,
|
||||
context.Options.AuditCollection,
|
||||
context.Options.MigrationsCollection
|
||||
};
|
||||
|
||||
var cursor = await context.Database
|
||||
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var existingNames = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var collection in requiredCollections)
|
||||
{
|
||||
if (existingNames.Contains(collection, StringComparer.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Creating Notify Mongo collection '{CollectionName}'.", collection);
|
||||
await context.Database.CreateCollectionAsync(collection, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal sealed class EnsureNotifyIndexesMigration : INotifyMongoMigration
|
||||
{
|
||||
public string Id => "20251019_notify_indexes_v1";
|
||||
|
||||
public async ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
await EnsureRulesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureChannelsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureTemplatesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeliveriesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDigestsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureLocksIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureAuditIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureIncidentsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureRulesIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.RulesCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("enabled");
|
||||
|
||||
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_enabled"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureChannelsIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ChannelsCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("type")
|
||||
.Ascending("enabled");
|
||||
|
||||
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_type_enabled"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureTemplatesIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.TemplatesCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("channelType")
|
||||
.Ascending("key")
|
||||
.Ascending("locale");
|
||||
|
||||
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_channel_key_locale",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureDeliveriesIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.DeliveriesCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Descending("sortKey");
|
||||
|
||||
var sortModel = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_sortKey"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(sortModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var statusModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("tenantId").Ascending("status"),
|
||||
new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_status"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(statusModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (context.Options.DeliveryHistoryRetention > TimeSpan.Zero)
|
||||
{
|
||||
var ttlModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("completedAt"),
|
||||
new CreateIndexOptions
|
||||
{
|
||||
Name = "completedAt_ttl",
|
||||
ExpireAfter = context.Options.DeliveryHistoryRetention
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(ttlModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureDigestsIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.DigestsCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("actionKey");
|
||||
|
||||
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_actionKey"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureLocksIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.LocksCollection);
|
||||
var uniqueModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("tenantId").Ascending("resource"),
|
||||
new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_resource",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(uniqueModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var ttlModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
|
||||
new CreateIndexOptions
|
||||
{
|
||||
Name = "expiresAt_ttl",
|
||||
ExpireAfter = TimeSpan.Zero
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(ttlModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureAuditIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.AuditCollection);
|
||||
var keys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Descending("timestamp");
|
||||
|
||||
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_timestamp"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EnsureIncidentsIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.IncidentsCollection);
|
||||
|
||||
// Tenant + status + time for filtering
|
||||
var statusKeys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Descending("lastOccurrence");
|
||||
|
||||
var statusModel = new CreateIndexModel<BsonDocument>(statusKeys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_status_lastOccurrence"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(statusModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Tenant + correlation key for fast lookup
|
||||
var correlationKeys = Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("correlationKey")
|
||||
.Ascending("status");
|
||||
|
||||
var correlationModel = new CreateIndexModel<BsonDocument>(correlationKeys, new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_correlationKey_status"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(correlationModel, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal sealed class EnsurePackApprovalsCollectionMigration : INotifyMongoMigration
|
||||
{
|
||||
private readonly ILogger<EnsurePackApprovalsCollectionMigration> _logger;
|
||||
|
||||
public EnsurePackApprovalsCollectionMigration(ILogger<EnsurePackApprovalsCollectionMigration> logger)
|
||||
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public string Id => "20251117_pack_approvals_collection_v1";
|
||||
|
||||
public async ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var target = context.Options.PackApprovalsCollection;
|
||||
|
||||
var cursor = await context.Database
|
||||
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var existing = new HashSet<string>(StringComparer.Ordinal);
|
||||
while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
existing.UnionWith(cursor.Current);
|
||||
}
|
||||
|
||||
if (existing.Contains(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Creating pack approvals collection '{Collection}'.", target);
|
||||
await context.Database.CreateCollectionAsync(target, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal sealed class EnsurePackApprovalsIndexesMigration : INotifyMongoMigration
|
||||
{
|
||||
public string Id => "20251117_pack_approvals_indexes_v1";
|
||||
|
||||
public async ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PackApprovalsCollection);
|
||||
|
||||
var unique = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("packId")
|
||||
.Ascending("eventId"),
|
||||
new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_pack_event",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(unique, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var issuedAt = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Descending("issuedAt"),
|
||||
new CreateIndexOptions
|
||||
{
|
||||
Name = "tenant_issuedAt"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(issuedAt, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal interface INotifyMongoMigration
|
||||
{
|
||||
string Id { get; }
|
||||
|
||||
ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal sealed class NotifyMongoMigrationRecord
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; init; }
|
||||
|
||||
[BsonElement("migrationId")]
|
||||
public required string MigrationId { get; init; }
|
||||
|
||||
[BsonElement("appliedAt")]
|
||||
public required DateTimeOffset AppliedAt { get; init; }
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
|
||||
internal sealed class NotifyMongoMigrationRunner
|
||||
{
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly IReadOnlyList<INotifyMongoMigration> _migrations;
|
||||
private readonly ILogger<NotifyMongoMigrationRunner> _logger;
|
||||
|
||||
public NotifyMongoMigrationRunner(
|
||||
NotifyMongoContext context,
|
||||
IEnumerable<INotifyMongoMigration> migrations,
|
||||
ILogger<NotifyMongoMigrationRunner> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
ArgumentNullException.ThrowIfNull(migrations);
|
||||
_migrations = migrations.OrderBy(migration => migration.Id, StringComparer.Ordinal).ToArray();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_migrations.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var collection = _context.Database.GetCollection<NotifyMongoMigrationRecord>(_context.Options.MigrationsCollection);
|
||||
await EnsureMigrationIndexAsync(collection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var applied = await collection
|
||||
.Find(FilterDefinition<NotifyMongoMigrationRecord>.Empty)
|
||||
.Project(record => record.MigrationId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var appliedSet = applied.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var migration in _migrations)
|
||||
{
|
||||
if (appliedSet.Contains(migration.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applying Notify Mongo migration {MigrationId}.", migration.Id);
|
||||
await migration.ExecuteAsync(_context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var record = new NotifyMongoMigrationRecord
|
||||
{
|
||||
Id = ObjectId.GenerateNewId(),
|
||||
MigrationId = migration.Id,
|
||||
AppliedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Completed Notify Mongo migration {MigrationId}.", migration.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureMigrationIndexAsync(
|
||||
IMongoCollection<NotifyMongoMigrationRecord> collection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var keys = Builders<NotifyMongoMigrationRecord>.IndexKeys.Ascending(record => record.MigrationId);
|
||||
var model = new CreateIndexModel<NotifyMongoMigrationRecord>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "migrationId_unique",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Options;
|
||||
|
||||
public sealed class NotifyMongoOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = "mongodb://localhost:27017";
|
||||
|
||||
public string Database { get; set; } = "stellaops_notify";
|
||||
|
||||
public string RulesCollection { get; set; } = "rules";
|
||||
|
||||
public string ChannelsCollection { get; set; } = "channels";
|
||||
|
||||
public string TemplatesCollection { get; set; } = "templates";
|
||||
|
||||
public string DeliveriesCollection { get; set; } = "deliveries";
|
||||
|
||||
public string DigestsCollection { get; set; } = "digests";
|
||||
|
||||
public string PackApprovalsCollection { get; set; } = "pack_approvals";
|
||||
|
||||
public string LocksCollection { get; set; } = "locks";
|
||||
|
||||
public string AuditCollection { get; set; } = "audit";
|
||||
|
||||
public string IncidentsCollection { get; set; } = "incidents";
|
||||
|
||||
public string MigrationsCollection { get; set; } = "_notify_migrations";
|
||||
|
||||
public string QuietHoursCollection { get; set; } = "quiet_hours";
|
||||
|
||||
public string MaintenanceWindowsCollection { get; set; } = "maintenance_windows";
|
||||
|
||||
public string ThrottleConfigsCollection { get; set; } = "throttle_configs";
|
||||
|
||||
public string OperatorOverridesCollection { get; set; } = "operator_overrides";
|
||||
|
||||
public string EscalationPoliciesCollection { get; set; } = "escalation_policies";
|
||||
|
||||
public string EscalationStatesCollection { get; set; } = "escalation_states";
|
||||
|
||||
public string OnCallSchedulesCollection { get; set; } = "oncall_schedules";
|
||||
|
||||
public string InboxCollection { get; set; } = "inbox";
|
||||
|
||||
public string LocalizationCollection { get; set; } = "localization";
|
||||
|
||||
public TimeSpan DeliveryHistoryRetention { get; set; } = TimeSpan.FromDays(90);
|
||||
|
||||
public bool UseMajorityReadConcern { get; set; } = true;
|
||||
|
||||
public bool UseMajorityWriteConcern { get; set; } = true;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Notify.Storage.Mongo.Tests")]
|
||||
@@ -1,10 +0,0 @@
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyAuditRepository
|
||||
{
|
||||
Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyChannelRepository
|
||||
{
|
||||
Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyDeliveryRepository
|
||||
{
|
||||
Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default);
|
||||
|
||||
Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyDeliveryQueryResult> QueryAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? since,
|
||||
string? status,
|
||||
int? limit,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyDigestRepository
|
||||
{
|
||||
Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default);
|
||||
|
||||
Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default);
|
||||
|
||||
Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for managing escalation policies.
|
||||
/// </summary>
|
||||
public interface INotifyEscalationPolicyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an escalation policy by ID.
|
||||
/// </summary>
|
||||
Task<NotifyEscalationPolicy?> GetAsync(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all escalation policies for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
|
||||
string tenantId,
|
||||
bool? enabledOnly = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates an escalation policy.
|
||||
/// </summary>
|
||||
Task UpsertAsync(
|
||||
NotifyEscalationPolicy policy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an escalation policy.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for managing escalation state.
|
||||
/// </summary>
|
||||
public interface INotifyEscalationStateRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets escalation state by ID.
|
||||
/// </summary>
|
||||
Task<NotifyEscalationState?> GetAsync(
|
||||
string tenantId,
|
||||
string stateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets escalation state for an incident.
|
||||
/// </summary>
|
||||
Task<NotifyEscalationState?> GetByIncidentAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists active escalation states due for processing.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyEscalationState>> ListDueForEscalationAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset now,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all escalation states for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyEscalationState>> ListAsync(
|
||||
string tenantId,
|
||||
NotifyEscalationStatus? status = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates an escalation state.
|
||||
/// </summary>
|
||||
Task UpsertAsync(
|
||||
NotifyEscalationState state,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the escalation state after a level transition.
|
||||
/// </summary>
|
||||
Task UpdateLevelAsync(
|
||||
string tenantId,
|
||||
string stateId,
|
||||
int newLevel,
|
||||
int newIteration,
|
||||
DateTimeOffset? nextEscalationAt,
|
||||
NotifyEscalationAttempt attempt,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an escalation.
|
||||
/// </summary>
|
||||
Task AcknowledgeAsync(
|
||||
string tenantId,
|
||||
string stateId,
|
||||
string acknowledgedBy,
|
||||
DateTimeOffset acknowledgedAt,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an escalation.
|
||||
/// </summary>
|
||||
Task ResolveAsync(
|
||||
string tenantId,
|
||||
string stateId,
|
||||
string resolvedBy,
|
||||
DateTimeOffset resolvedAt,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an escalation state.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
string tenantId,
|
||||
string stateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for in-app inbox message storage.
|
||||
/// </summary>
|
||||
public interface INotifyInboxRepository
|
||||
{
|
||||
Task<string> StoreAsync(NotifyInboxMessage message, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<NotifyInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
|
||||
Task<NotifyInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
Task DeleteExpiredAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox message model for storage.
|
||||
/// </summary>
|
||||
public sealed record NotifyInboxMessage
|
||||
{
|
||||
public required string MessageId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public required string Category { get; init; }
|
||||
public int Priority { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public DateTimeOffset? ReadAt { get; set; }
|
||||
public bool IsRead => ReadAt.HasValue;
|
||||
public string? SourceChannel { get; init; }
|
||||
public string? DeliveryId { get; init; }
|
||||
public bool IsDeleted { get; set; }
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for persisting and querying notification incidents.
|
||||
/// </summary>
|
||||
public interface INotifyIncidentRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Upserts an incident.
|
||||
/// </summary>
|
||||
Task UpsertAsync(IncidentState incident, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an incident by ID.
|
||||
/// </summary>
|
||||
Task<IncidentState?> GetAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an incident by correlation key.
|
||||
/// </summary>
|
||||
Task<IncidentState?> GetByCorrelationKeyAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists incidents for a tenant with optional filtering.
|
||||
/// </summary>
|
||||
Task<NotifyIncidentQueryResult> QueryAsync(
|
||||
string tenantId,
|
||||
IncidentStatus? status = null,
|
||||
DateTimeOffset? since = null,
|
||||
DateTimeOffset? until = null,
|
||||
int limit = 100,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an incident (soft delete).
|
||||
/// </summary>
|
||||
Task DeleteAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an incident query with pagination support.
|
||||
/// </summary>
|
||||
public sealed record NotifyIncidentQueryResult(
|
||||
IReadOnlyList<IncidentState> Items,
|
||||
string? ContinuationToken);
|
||||
@@ -1,65 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for managing localization bundles.
|
||||
/// </summary>
|
||||
public interface INotifyLocalizationRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a localization bundle by ID.
|
||||
/// </summary>
|
||||
Task<NotifyLocalizationBundle?> GetAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localization bundle by key and locale.
|
||||
/// </summary>
|
||||
Task<NotifyLocalizationBundle?> GetByKeyAndLocaleAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all localization bundles for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyLocalizationBundle>> ListAsync(
|
||||
string tenantId,
|
||||
string? bundleKey = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all locales available for a bundle key.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> ListLocalesAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a localization bundle.
|
||||
/// </summary>
|
||||
Task UpsertAsync(
|
||||
NotifyLocalizationBundle bundle,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a localization bundle.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default bundle for a bundle key.
|
||||
/// </summary>
|
||||
Task<NotifyLocalizationBundle?> GetDefaultAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyLockRepository
|
||||
{
|
||||
Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for maintenance windows.
|
||||
/// </summary>
|
||||
public interface INotifyMaintenanceWindowRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts or updates a maintenance window.
|
||||
/// </summary>
|
||||
Task UpsertAsync(NotifyMaintenanceWindow window, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a maintenance window by ID.
|
||||
/// </summary>
|
||||
Task<NotifyMaintenanceWindow?> GetAsync(string tenantId, string windowId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all maintenance windows for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
|
||||
string tenantId,
|
||||
bool? activeOnly = null,
|
||||
DateTimeOffset? asOf = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active maintenance windows for a tenant at a specific point in time.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyMaintenanceWindow>> GetActiveAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a maintenance window.
|
||||
/// </summary>
|
||||
Task DeleteAsync(string tenantId, string windowId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for managing on-call schedules.
|
||||
/// </summary>
|
||||
public interface INotifyOnCallScheduleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an on-call schedule by ID.
|
||||
/// </summary>
|
||||
Task<NotifyOnCallSchedule?> GetAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all on-call schedules for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
|
||||
string tenantId,
|
||||
bool? enabledOnly = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates an on-call schedule.
|
||||
/// </summary>
|
||||
Task UpsertAsync(
|
||||
NotifyOnCallSchedule schedule,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an override to a schedule.
|
||||
/// </summary>
|
||||
Task AddOverrideAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
NotifyOnCallOverride override_,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an override from a schedule.
|
||||
/// </summary>
|
||||
Task RemoveOverrideAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
string overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an on-call schedule.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for operator overrides.
|
||||
/// </summary>
|
||||
public interface INotifyOperatorOverrideRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts or updates an operator override.
|
||||
/// </summary>
|
||||
Task UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an operator override by ID.
|
||||
/// </summary>
|
||||
Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all active operator overrides for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyOperatorOverride>> ListActiveAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset asOf,
|
||||
NotifyOverrideType? overrideType = null,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all operator overrides for a tenant (including expired).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(
|
||||
string tenantId,
|
||||
bool? activeOnly = null,
|
||||
DateTimeOffset? asOf = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an operator override.
|
||||
/// </summary>
|
||||
Task DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all expired operator overrides for a tenant.
|
||||
/// </summary>
|
||||
Task DeleteExpiredAsync(string tenantId, DateTimeOffset olderThan, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyPackApprovalRepository
|
||||
{
|
||||
Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for quiet hours schedules.
|
||||
/// </summary>
|
||||
public interface INotifyQuietHoursRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts or updates a quiet hours schedule.
|
||||
/// </summary>
|
||||
Task UpsertAsync(NotifyQuietHoursSchedule schedule, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a quiet hours schedule by ID.
|
||||
/// </summary>
|
||||
Task<NotifyQuietHoursSchedule?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all quiet hours schedules for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
|
||||
string tenantId,
|
||||
string? channelId = null,
|
||||
bool? enabledOnly = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all enabled quiet hours schedules for a tenant, optionally filtered by channel.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListEnabledAsync(
|
||||
string tenantId,
|
||||
string? channelId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a quiet hours schedule.
|
||||
/// </summary>
|
||||
Task DeleteAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyRuleRepository
|
||||
{
|
||||
Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public interface INotifyTemplateRepository
|
||||
{
|
||||
Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for throttle configurations.
|
||||
/// </summary>
|
||||
public interface INotifyThrottleConfigRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts or updates a throttle configuration.
|
||||
/// </summary>
|
||||
Task UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a throttle configuration by ID.
|
||||
/// </summary>
|
||||
Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default throttle configuration for a tenant.
|
||||
/// </summary>
|
||||
Task<NotifyThrottleConfig?> GetDefaultAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the throttle configuration for a specific channel.
|
||||
/// </summary>
|
||||
Task<NotifyThrottleConfig?> GetForChannelAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all throttle configurations for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a throttle configuration.
|
||||
/// </summary>
|
||||
Task DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyAuditRepository : INotifyAuditRepository
|
||||
{
|
||||
private readonly IMongoCollection<NotifyAuditEntryDocument> _collection;
|
||||
|
||||
public NotifyAuditRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<NotifyAuditEntryDocument>(context.Options.AuditCollection);
|
||||
}
|
||||
|
||||
public async Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
await _collection.InsertOneAsync(entry, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<NotifyAuditEntryDocument>.Filter.Eq(x => x.TenantId, tenantId);
|
||||
if (since is not null)
|
||||
{
|
||||
filter &= Builders<NotifyAuditEntryDocument>.Filter.Gte(x => x.Timestamp, since.Value);
|
||||
}
|
||||
|
||||
var ordered = _collection.Find(filter).SortByDescending(x => x.Timestamp);
|
||||
IFindFluent<NotifyAuditEntryDocument, NotifyAuditEntryDocument> query = ordered;
|
||||
if (limit is > 0)
|
||||
{
|
||||
query = query.Limit(limit);
|
||||
}
|
||||
|
||||
return await query.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
using StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyChannelRepository : INotifyChannelRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public NotifyChannelRepository(NotifyMongoContext context, ITenantContext? tenantContext = null)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.ChannelsCollection);
|
||||
_tenantContext = tenantContext ?? NullTenantContext.Instance;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
_tenantContext.ValidateTenant(channel.TenantId);
|
||||
|
||||
var document = NotifyChannelDocumentMapper.ToBsonDocument(channel);
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(channel.TenantId, channel.ChannelId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", channel.TenantId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and explicit tenantId check
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, channelId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : NotifyChannelDocumentMapper.FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(NotifyChannelDocumentMapper.FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, channelId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId));
|
||||
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow).Set("enabled", false),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public sealed record NotifyDeliveryQueryResult(IReadOnlyList<NotifyDelivery> Items, string? ContinuationToken);
|
||||
@@ -1,179 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyDeliveryRepository : INotifyDeliveryRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
|
||||
public NotifyDeliveryRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.DeliveriesCollection);
|
||||
}
|
||||
|
||||
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
=> UpdateAsync(delivery, cancellationToken);
|
||||
|
||||
public async Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(delivery);
|
||||
var document = NotifyDeliveryDocumentMapper.ToBsonDocument(delivery);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(delivery.TenantId, delivery.DeliveryId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, deliveryId));
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : NotifyDeliveryDocumentMapper.FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<NotifyDeliveryQueryResult> QueryAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? since,
|
||||
string? status,
|
||||
int? limit,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var builder = Builders<BsonDocument>.Filter;
|
||||
var filter = builder.Eq("tenantId", tenantId);
|
||||
if (since is not null)
|
||||
{
|
||||
filter &= builder.Gte("sortKey", since.Value.UtcDateTime);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
var statuses = status
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static value => value.ToLowerInvariant())
|
||||
.ToArray();
|
||||
|
||||
if (statuses.Length == 1)
|
||||
{
|
||||
filter &= builder.Eq("status", statuses[0]);
|
||||
}
|
||||
else if (statuses.Length > 1)
|
||||
{
|
||||
filter &= builder.In("status", statuses);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(continuationToken))
|
||||
{
|
||||
if (!TryParseContinuationToken(continuationToken, out var continuationSortKey, out var continuationId))
|
||||
{
|
||||
throw new ArgumentException("The continuation token is invalid.", nameof(continuationToken));
|
||||
}
|
||||
|
||||
var lessThanSort = builder.Lt("sortKey", continuationSortKey);
|
||||
var equalSortLowerId = builder.And(builder.Eq("sortKey", continuationSortKey), builder.Lte("_id", continuationId));
|
||||
filter &= builder.Or(lessThanSort, equalSortLowerId);
|
||||
}
|
||||
|
||||
var find = _collection.Find(filter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Descending("sortKey").Descending("_id"));
|
||||
|
||||
List<BsonDocument> documents;
|
||||
if (limit is > 0)
|
||||
{
|
||||
documents = await find.Limit(limit.Value + 1).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
documents = await find.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
string? nextToken = null;
|
||||
if (limit is > 0 && documents.Count > limit.Value)
|
||||
{
|
||||
var overflow = documents[^1];
|
||||
documents.RemoveAt(documents.Count - 1);
|
||||
nextToken = BuildContinuationToken(overflow);
|
||||
}
|
||||
|
||||
var deliveries = documents.Select(NotifyDeliveryDocumentMapper.FromBsonDocument).ToArray();
|
||||
return new NotifyDeliveryQueryResult(deliveries, nextToken);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
|
||||
private static string BuildContinuationToken(BsonDocument document)
|
||||
{
|
||||
var sortKey = ResolveSortKey(document);
|
||||
if (!document.TryGetValue("_id", out var idValue) || !idValue.IsString)
|
||||
{
|
||||
throw new InvalidOperationException("Delivery document missing string _id required for continuation token.");
|
||||
}
|
||||
|
||||
return BuildContinuationToken(sortKey, idValue.AsString);
|
||||
}
|
||||
|
||||
private static DateTime ResolveSortKey(BsonDocument document)
|
||||
{
|
||||
if (document.TryGetValue("sortKey", out var sortValue) && sortValue.IsValidDateTime)
|
||||
{
|
||||
return sortValue.ToUniversalTime();
|
||||
}
|
||||
|
||||
if (document.TryGetValue("completedAt", out var completed) && completed.IsValidDateTime)
|
||||
{
|
||||
return completed.ToUniversalTime();
|
||||
}
|
||||
|
||||
if (document.TryGetValue("sentAt", out var sent) && sent.IsValidDateTime)
|
||||
{
|
||||
return sent.ToUniversalTime();
|
||||
}
|
||||
|
||||
var created = document["createdAt"];
|
||||
return created.ToUniversalTime();
|
||||
}
|
||||
|
||||
private static string BuildContinuationToken(DateTime sortKey, string id)
|
||||
=> FormattableString.Invariant($"{sortKey:O}|{id}");
|
||||
|
||||
private static bool TryParseContinuationToken(string token, out DateTime sortKey, out string id)
|
||||
{
|
||||
sortKey = default;
|
||||
id = string.Empty;
|
||||
|
||||
var parts = token.Split('|', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParseExact(parts[0], "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedSort))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
sortKey = parsedSort.ToUniversalTime();
|
||||
id = parts[1];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyDigestRepository : INotifyDigestRepository
|
||||
{
|
||||
private readonly IMongoCollection<NotifyDigestDocument> _collection;
|
||||
|
||||
public NotifyDigestRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<NotifyDigestDocument>(context.Options.DigestsCollection);
|
||||
}
|
||||
|
||||
public async Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<NotifyDigestDocument>.Filter.Eq(x => x.Id, CreateDocumentId(tenantId, actionKey));
|
||||
return await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
document.Id = CreateDocumentId(document.TenantId, document.ActionKey);
|
||||
var filter = Builders<NotifyDigestDocument>.Filter.Eq(x => x.Id, document.Id);
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<NotifyDigestDocument>.Filter.Eq(x => x.Id, CreateDocumentId(tenantId, actionKey));
|
||||
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string actionKey)
|
||||
=> string.Create(tenantId.Length + actionKey.Length + 1, (tenantId, actionKey), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.actionKey.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Collections.Immutable;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB implementation of escalation policy repository.
|
||||
/// </summary>
|
||||
internal sealed class NotifyEscalationPolicyRepository : INotifyEscalationPolicyRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public NotifyEscalationPolicyRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.EscalationPoliciesCollection);
|
||||
}
|
||||
|
||||
public async Task<NotifyEscalationPolicy?> GetAsync(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, policyId))
|
||||
& NotDeletedFilter();
|
||||
|
||||
var doc = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc is null ? null : FromBsonDocument(doc);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
|
||||
string tenantId,
|
||||
bool? enabledOnly = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
|
||||
|
||||
if (enabledOnly == true)
|
||||
{
|
||||
filter &= Builders<BsonDocument>.Filter.Eq("enabled", true);
|
||||
}
|
||||
|
||||
var docs = await _collection.Find(filter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Ascending("name"))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return docs.Select(FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(
|
||||
NotifyEscalationPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
var doc = ToBsonDocument(policy);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(policy.TenantId, policy.PolicyId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, policyId));
|
||||
await _collection.UpdateOneAsync(
|
||||
filter,
|
||||
Builders<BsonDocument>.Update
|
||||
.Set("deletedAt", DateTime.UtcNow)
|
||||
.Set("enabled", false),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static FilterDefinition<BsonDocument> NotDeletedFilter()
|
||||
=> Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
|
||||
private static BsonDocument ToBsonDocument(NotifyEscalationPolicy policy)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(policy, JsonOptions);
|
||||
var document = BsonDocument.Parse(json);
|
||||
document["_id"] = CreateDocumentId(policy.TenantId, policy.PolicyId);
|
||||
|
||||
// Convert level escalateAfter to ticks for storage
|
||||
if (document.Contains("levels") && document["levels"].IsBsonArray)
|
||||
{
|
||||
foreach (var level in document["levels"].AsBsonArray)
|
||||
{
|
||||
if (level.IsBsonDocument && level.AsBsonDocument.Contains("escalateAfter"))
|
||||
{
|
||||
var escalateAfter = level.AsBsonDocument["escalateAfter"].AsString;
|
||||
if (TimeSpan.TryParse(escalateAfter, out var ts))
|
||||
{
|
||||
level.AsBsonDocument["escalateAfterTicks"] = ts.Ticks;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static NotifyEscalationPolicy FromBsonDocument(BsonDocument doc)
|
||||
{
|
||||
var levels = new List<NotifyEscalationLevel>();
|
||||
if (doc.Contains("levels") && doc["levels"].IsBsonArray)
|
||||
{
|
||||
foreach (var levelVal in doc["levels"].AsBsonArray)
|
||||
{
|
||||
if (levelVal.IsBsonDocument)
|
||||
{
|
||||
levels.Add(LevelFromBson(levelVal.AsBsonDocument));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var metadata = ExtractStringDictionary(doc, "metadata");
|
||||
|
||||
return NotifyEscalationPolicy.Create(
|
||||
policyId: doc["policyId"].AsString,
|
||||
tenantId: doc["tenantId"].AsString,
|
||||
name: doc["name"].AsString,
|
||||
levels: levels,
|
||||
enabled: doc.Contains("enabled") ? doc["enabled"].AsBoolean : true,
|
||||
repeatEnabled: doc.Contains("repeatEnabled") ? doc["repeatEnabled"].AsBoolean : false,
|
||||
repeatCount: doc.Contains("repeatCount") && doc["repeatCount"] != BsonNull.Value ? doc["repeatCount"].AsInt32 : null,
|
||||
description: doc.Contains("description") && doc["description"] != BsonNull.Value ? doc["description"].AsString : null,
|
||||
metadata: metadata,
|
||||
createdBy: doc.Contains("createdBy") && doc["createdBy"] != BsonNull.Value ? doc["createdBy"].AsString : null,
|
||||
createdAt: doc.Contains("createdAt") ? DateTimeOffset.Parse(doc["createdAt"].AsString) : null,
|
||||
updatedBy: doc.Contains("updatedBy") && doc["updatedBy"] != BsonNull.Value ? doc["updatedBy"].AsString : null,
|
||||
updatedAt: doc.Contains("updatedAt") ? DateTimeOffset.Parse(doc["updatedAt"].AsString) : null);
|
||||
}
|
||||
|
||||
private static NotifyEscalationLevel LevelFromBson(BsonDocument doc)
|
||||
{
|
||||
var escalateAfter = doc.Contains("escalateAfterTicks")
|
||||
? TimeSpan.FromTicks(doc["escalateAfterTicks"].AsInt64)
|
||||
: TimeSpan.FromMinutes(15);
|
||||
|
||||
var targets = new List<NotifyEscalationTarget>();
|
||||
if (doc.Contains("targets") && doc["targets"].IsBsonArray)
|
||||
{
|
||||
foreach (var targetVal in doc["targets"].AsBsonArray)
|
||||
{
|
||||
if (targetVal.IsBsonDocument)
|
||||
{
|
||||
var td = targetVal.AsBsonDocument;
|
||||
targets.Add(NotifyEscalationTarget.Create(
|
||||
Enum.Parse<NotifyEscalationTargetType>(td["type"].AsString),
|
||||
td["targetId"].AsString,
|
||||
td.Contains("channelOverride") && td["channelOverride"] != BsonNull.Value ? td["channelOverride"].AsString : null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NotifyEscalationLevel.Create(
|
||||
order: doc["order"].AsInt32,
|
||||
escalateAfter: escalateAfter,
|
||||
targets: targets,
|
||||
name: doc.Contains("name") && doc["name"] != BsonNull.Value ? doc["name"].AsString : null,
|
||||
notifyAll: doc.Contains("notifyAll") ? doc["notifyAll"].AsBoolean : true);
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>>? ExtractStringDictionary(BsonDocument document, string key)
|
||||
{
|
||||
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonDocument)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = document[key].AsBsonDocument;
|
||||
return dict.Elements.Select(e => new KeyValuePair<string, string>(e.Name, e.Value.AsString));
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> $"{tenantId}:{resourceId}";
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Collections.Immutable;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB implementation of escalation state repository.
|
||||
/// </summary>
|
||||
internal sealed class NotifyEscalationStateRepository : INotifyEscalationStateRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
|
||||
public NotifyEscalationStateRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.EscalationStatesCollection);
|
||||
}
|
||||
|
||||
public async Task<NotifyEscalationState?> GetAsync(
|
||||
string tenantId,
|
||||
string stateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, stateId));
|
||||
var doc = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc is null ? null : FromBsonDocument(doc);
|
||||
}
|
||||
|
||||
public async Task<NotifyEscalationState?> GetByIncidentAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("incidentId", incidentId));
|
||||
|
||||
var doc = await _collection.Find(filter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Descending("createdAt"))
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return doc is null ? null : FromBsonDocument(doc);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyEscalationState>> ListDueForEscalationAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset now,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("status", NotifyEscalationStatus.Active.ToString()),
|
||||
Builders<BsonDocument>.Filter.Lte("nextEscalationAt", now.UtcDateTime));
|
||||
|
||||
var docs = await _collection.Find(filter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Ascending("nextEscalationAt"))
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return docs.Select(FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyEscalationState>> ListAsync(
|
||||
string tenantId,
|
||||
NotifyEscalationStatus? status = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
filter &= Builders<BsonDocument>.Filter.Eq("status", status.Value.ToString());
|
||||
}
|
||||
|
||||
var docs = await _collection.Find(filter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Descending("createdAt"))
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return docs.Select(FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(
|
||||
NotifyEscalationState state,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
var doc = ToBsonDocument(state);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(state.TenantId, state.StateId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpdateLevelAsync(
|
||||
string tenantId,
|
||||
string stateId,
|
||||
int newLevel,
|
||||
int newIteration,
|
||||
DateTimeOffset? nextEscalationAt,
|
||||
NotifyEscalationAttempt attempt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, stateId));
|
||||
|
||||
var attemptDoc = AttemptToBson(attempt);
|
||||
|
||||
var updateBuilder = Builders<BsonDocument>.Update;
|
||||
var updates = new List<UpdateDefinition<BsonDocument>>
|
||||
{
|
||||
updateBuilder.Set("currentLevel", newLevel),
|
||||
updateBuilder.Set("repeatIteration", newIteration),
|
||||
updateBuilder.Set("updatedAt", DateTime.UtcNow),
|
||||
updateBuilder.Push("attempts", attemptDoc)
|
||||
};
|
||||
|
||||
if (nextEscalationAt.HasValue)
|
||||
{
|
||||
updates.Add(updateBuilder.Set("nextEscalationAt", nextEscalationAt.Value.UtcDateTime));
|
||||
}
|
||||
else
|
||||
{
|
||||
updates.Add(updateBuilder.Unset("nextEscalationAt"));
|
||||
}
|
||||
|
||||
var update = updateBuilder.Combine(updates);
|
||||
await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task AcknowledgeAsync(
|
||||
string tenantId,
|
||||
string stateId,
|
||||
string acknowledgedBy,
|
||||
DateTimeOffset acknowledgedAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, stateId));
|
||||
|
||||
var update = Builders<BsonDocument>.Update
|
||||
.Set("status", NotifyEscalationStatus.Acknowledged.ToString())
|
||||
.Set("acknowledgedBy", acknowledgedBy)
|
||||
.Set("acknowledgedAt", acknowledgedAt.UtcDateTime)
|
||||
.Set("updatedAt", DateTime.UtcNow)
|
||||
.Unset("nextEscalationAt");
|
||||
|
||||
await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ResolveAsync(
|
||||
string tenantId,
|
||||
string stateId,
|
||||
string resolvedBy,
|
||||
DateTimeOffset resolvedAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, stateId));
|
||||
|
||||
var update = Builders<BsonDocument>.Update
|
||||
.Set("status", NotifyEscalationStatus.Resolved.ToString())
|
||||
.Set("resolvedBy", resolvedBy)
|
||||
.Set("resolvedAt", resolvedAt.UtcDateTime)
|
||||
.Set("updatedAt", DateTime.UtcNow)
|
||||
.Unset("nextEscalationAt");
|
||||
|
||||
await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(
|
||||
string tenantId,
|
||||
string stateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, stateId));
|
||||
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static BsonDocument ToBsonDocument(NotifyEscalationState state)
|
||||
{
|
||||
var doc = new BsonDocument
|
||||
{
|
||||
["_id"] = CreateDocumentId(state.TenantId, state.StateId),
|
||||
["stateId"] = state.StateId,
|
||||
["tenantId"] = state.TenantId,
|
||||
["incidentId"] = state.IncidentId,
|
||||
["policyId"] = state.PolicyId,
|
||||
["currentLevel"] = state.CurrentLevel,
|
||||
["repeatIteration"] = state.RepeatIteration,
|
||||
["status"] = state.Status.ToString(),
|
||||
["createdAt"] = state.CreatedAt.UtcDateTime,
|
||||
["updatedAt"] = state.UpdatedAt.UtcDateTime
|
||||
};
|
||||
|
||||
if (state.NextEscalationAt.HasValue)
|
||||
doc["nextEscalationAt"] = state.NextEscalationAt.Value.UtcDateTime;
|
||||
|
||||
if (state.AcknowledgedAt.HasValue)
|
||||
doc["acknowledgedAt"] = state.AcknowledgedAt.Value.UtcDateTime;
|
||||
|
||||
if (state.AcknowledgedBy is not null)
|
||||
doc["acknowledgedBy"] = state.AcknowledgedBy;
|
||||
|
||||
if (state.ResolvedAt.HasValue)
|
||||
doc["resolvedAt"] = state.ResolvedAt.Value.UtcDateTime;
|
||||
|
||||
if (state.ResolvedBy is not null)
|
||||
doc["resolvedBy"] = state.ResolvedBy;
|
||||
|
||||
var attempts = new BsonArray();
|
||||
foreach (var attempt in state.Attempts)
|
||||
{
|
||||
attempts.Add(AttemptToBson(attempt));
|
||||
}
|
||||
doc["attempts"] = attempts;
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static BsonDocument AttemptToBson(NotifyEscalationAttempt attempt)
|
||||
{
|
||||
var doc = new BsonDocument
|
||||
{
|
||||
["level"] = attempt.Level,
|
||||
["iteration"] = attempt.Iteration,
|
||||
["timestamp"] = attempt.Timestamp.UtcDateTime,
|
||||
["success"] = attempt.Success,
|
||||
["notifiedTargets"] = new BsonArray(attempt.NotifiedTargets)
|
||||
};
|
||||
|
||||
if (attempt.FailureReason is not null)
|
||||
doc["failureReason"] = attempt.FailureReason;
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static NotifyEscalationState FromBsonDocument(BsonDocument doc)
|
||||
{
|
||||
var attempts = new List<NotifyEscalationAttempt>();
|
||||
if (doc.Contains("attempts") && doc["attempts"].IsBsonArray)
|
||||
{
|
||||
foreach (var attemptVal in doc["attempts"].AsBsonArray)
|
||||
{
|
||||
if (attemptVal.IsBsonDocument)
|
||||
{
|
||||
var ad = attemptVal.AsBsonDocument;
|
||||
attempts.Add(new NotifyEscalationAttempt(
|
||||
level: ad["level"].AsInt32,
|
||||
iteration: ad["iteration"].AsInt32,
|
||||
timestamp: new DateTimeOffset(ad["timestamp"].ToUniversalTime(), TimeSpan.Zero),
|
||||
notifiedTargets: ad.GetValue("notifiedTargets", new BsonArray()).AsBsonArray
|
||||
.Select(t => t.AsString)
|
||||
.ToImmutableArray(),
|
||||
success: ad["success"].AsBoolean,
|
||||
failureReason: ad.Contains("failureReason") && ad["failureReason"] != BsonNull.Value
|
||||
? ad["failureReason"].AsString
|
||||
: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NotifyEscalationState.Create(
|
||||
stateId: doc["stateId"].AsString,
|
||||
tenantId: doc["tenantId"].AsString,
|
||||
incidentId: doc["incidentId"].AsString,
|
||||
policyId: doc["policyId"].AsString,
|
||||
currentLevel: doc["currentLevel"].AsInt32,
|
||||
repeatIteration: doc["repeatIteration"].AsInt32,
|
||||
status: Enum.Parse<NotifyEscalationStatus>(doc["status"].AsString),
|
||||
attempts: attempts,
|
||||
nextEscalationAt: doc.Contains("nextEscalationAt") && doc["nextEscalationAt"] != BsonNull.Value
|
||||
? new DateTimeOffset(doc["nextEscalationAt"].ToUniversalTime(), TimeSpan.Zero)
|
||||
: null,
|
||||
createdAt: new DateTimeOffset(doc["createdAt"].ToUniversalTime(), TimeSpan.Zero),
|
||||
updatedAt: new DateTimeOffset(doc["updatedAt"].ToUniversalTime(), TimeSpan.Zero),
|
||||
acknowledgedAt: doc.Contains("acknowledgedAt") && doc["acknowledgedAt"] != BsonNull.Value
|
||||
? new DateTimeOffset(doc["acknowledgedAt"].ToUniversalTime(), TimeSpan.Zero)
|
||||
: null,
|
||||
acknowledgedBy: doc.Contains("acknowledgedBy") && doc["acknowledgedBy"] != BsonNull.Value
|
||||
? doc["acknowledgedBy"].AsString
|
||||
: null,
|
||||
resolvedAt: doc.Contains("resolvedAt") && doc["resolvedAt"] != BsonNull.Value
|
||||
? new DateTimeOffset(doc["resolvedAt"].ToUniversalTime(), TimeSpan.Zero)
|
||||
: null,
|
||||
resolvedBy: doc.Contains("resolvedBy") && doc["resolvedBy"] != BsonNull.Value
|
||||
? doc["resolvedBy"].AsString
|
||||
: null);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> $"{tenantId}:{resourceId}";
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB implementation of in-app inbox message storage.
|
||||
/// </summary>
|
||||
internal sealed class NotifyInboxRepository : INotifyInboxRepository
|
||||
{
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly string _collectionName;
|
||||
|
||||
public NotifyInboxRepository(NotifyMongoContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_collectionName = _context.Options.InboxCollection;
|
||||
}
|
||||
|
||||
private IMongoCollection<BsonDocument> GetCollection() =>
|
||||
_context.Database.GetCollection<BsonDocument>(_collectionName);
|
||||
|
||||
public async Task<string> StoreAsync(NotifyInboxMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var doc = new BsonDocument
|
||||
{
|
||||
["_id"] = message.MessageId,
|
||||
["tenantId"] = message.TenantId,
|
||||
["userId"] = message.UserId,
|
||||
["title"] = message.Title,
|
||||
["body"] = message.Body,
|
||||
["summary"] = message.Summary is not null ? (BsonValue)message.Summary : BsonNull.Value,
|
||||
["category"] = message.Category,
|
||||
["priority"] = message.Priority,
|
||||
["metadata"] = message.Metadata is not null
|
||||
? new BsonDocument(message.Metadata.ToDictionary(kv => kv.Key, kv => (BsonValue)kv.Value))
|
||||
: BsonNull.Value,
|
||||
["createdAt"] = message.CreatedAt.UtcDateTime,
|
||||
["expiresAt"] = message.ExpiresAt.HasValue ? (BsonValue)message.ExpiresAt.Value.UtcDateTime : BsonNull.Value,
|
||||
["readAt"] = BsonNull.Value,
|
||||
["sourceChannel"] = message.SourceChannel is not null ? (BsonValue)message.SourceChannel : BsonNull.Value,
|
||||
["deliveryId"] = message.DeliveryId is not null ? (BsonValue)message.DeliveryId : BsonNull.Value,
|
||||
["isDeleted"] = false
|
||||
};
|
||||
|
||||
await GetCollection().InsertOneAsync(doc, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return message.MessageId;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyInboxMessage>> GetForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("userId", userId),
|
||||
Builders<BsonDocument>.Filter.Ne("isDeleted", true),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Eq("expiresAt", BsonNull.Value),
|
||||
Builders<BsonDocument>.Filter.Gt("expiresAt", DateTime.UtcNow)
|
||||
)
|
||||
);
|
||||
|
||||
var sort = Builders<BsonDocument>.Sort.Descending("createdAt");
|
||||
|
||||
var docs = await GetCollection()
|
||||
.Find(filter)
|
||||
.Sort(sort)
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return docs.Select(MapFromBson).ToList();
|
||||
}
|
||||
|
||||
public async Task<NotifyInboxMessage?> GetAsync(
|
||||
string tenantId,
|
||||
string messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(messageId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", messageId),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Ne("isDeleted", true)
|
||||
);
|
||||
|
||||
var doc = await GetCollection()
|
||||
.Find(filter)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return doc is null ? null : MapFromBson(doc);
|
||||
}
|
||||
|
||||
public async Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(messageId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", messageId),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
);
|
||||
|
||||
var update = Builders<BsonDocument>.Update.Set("readAt", DateTime.UtcNow);
|
||||
|
||||
await GetCollection().UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("userId", userId),
|
||||
Builders<BsonDocument>.Filter.Eq("readAt", BsonNull.Value)
|
||||
);
|
||||
|
||||
var update = Builders<BsonDocument>.Update.Set("readAt", DateTime.UtcNow);
|
||||
|
||||
await GetCollection().UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(messageId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", messageId),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
);
|
||||
|
||||
var update = Builders<BsonDocument>.Update.Set("isDeleted", true);
|
||||
|
||||
await GetCollection().UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("userId", userId),
|
||||
Builders<BsonDocument>.Filter.Eq("readAt", BsonNull.Value),
|
||||
Builders<BsonDocument>.Filter.Ne("isDeleted", true),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Eq("expiresAt", BsonNull.Value),
|
||||
Builders<BsonDocument>.Filter.Gt("expiresAt", DateTime.UtcNow)
|
||||
)
|
||||
);
|
||||
|
||||
var count = await GetCollection().CountDocumentsAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return (int)count;
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Lt("expiresAt", DateTime.UtcNow),
|
||||
Builders<BsonDocument>.Filter.Ne("expiresAt", BsonNull.Value)
|
||||
);
|
||||
|
||||
await GetCollection().DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static NotifyInboxMessage MapFromBson(BsonDocument doc)
|
||||
{
|
||||
return new NotifyInboxMessage
|
||||
{
|
||||
MessageId = doc["_id"].AsString,
|
||||
TenantId = doc["tenantId"].AsString,
|
||||
UserId = doc["userId"].AsString,
|
||||
Title = doc["title"].AsString,
|
||||
Body = doc["body"].AsString,
|
||||
Summary = doc["summary"].IsBsonNull ? null : doc["summary"].AsString,
|
||||
Category = doc["category"].AsString,
|
||||
Priority = doc.Contains("priority") ? doc["priority"].AsInt32 : 0,
|
||||
Metadata = doc["metadata"].IsBsonNull
|
||||
? null
|
||||
: doc["metadata"].AsBsonDocument.ToDictionary(e => e.Name, e => e.Value.AsString),
|
||||
CreatedAt = new DateTimeOffset(doc["createdAt"].ToUniversalTime(), TimeSpan.Zero),
|
||||
ExpiresAt = doc["expiresAt"].IsBsonNull
|
||||
? null
|
||||
: new DateTimeOffset(doc["expiresAt"].ToUniversalTime(), TimeSpan.Zero),
|
||||
ReadAt = doc["readAt"].IsBsonNull
|
||||
? null
|
||||
: new DateTimeOffset(doc["readAt"].ToUniversalTime(), TimeSpan.Zero),
|
||||
SourceChannel = doc.Contains("sourceChannel") && !doc["sourceChannel"].IsBsonNull
|
||||
? doc["sourceChannel"].AsString
|
||||
: null,
|
||||
DeliveryId = doc.Contains("deliveryId") && !doc["deliveryId"].IsBsonNull
|
||||
? doc["deliveryId"].AsString
|
||||
: null,
|
||||
IsDeleted = doc.Contains("isDeleted") && doc["isDeleted"].AsBoolean
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
using System.Globalization;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyIncidentRepository : INotifyIncidentRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
|
||||
public NotifyIncidentRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.IncidentsCollection);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(IncidentState incident, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incident);
|
||||
var document = NotifyIncidentDocumentMapper.ToBsonDocument(incident);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(incident.TenantId, incident.IncidentId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IncidentState?> GetAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, incidentId))
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : NotifyIncidentDocumentMapper.FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<IncidentState?> GetByCorrelationKeyAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
& Builders<BsonDocument>.Filter.Eq("correlationKey", correlationKey)
|
||||
& Builders<BsonDocument>.Filter.Ne("status", "resolved")
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
|
||||
var document = await _collection.Find(filter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Descending("lastOccurrence"))
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return document is null ? null : NotifyIncidentDocumentMapper.FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<NotifyIncidentQueryResult> QueryAsync(
|
||||
string tenantId,
|
||||
IncidentStatus? status = null,
|
||||
DateTimeOffset? since = null,
|
||||
DateTimeOffset? until = null,
|
||||
int limit = 100,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var builder = Builders<BsonDocument>.Filter;
|
||||
var filter = builder.Eq("tenantId", tenantId)
|
||||
& builder.Or(
|
||||
builder.Exists("deletedAt", false),
|
||||
builder.Eq("deletedAt", BsonNull.Value));
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
filter &= builder.Eq("status", status.Value.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
if (since.HasValue)
|
||||
{
|
||||
filter &= builder.Gte("lastOccurrence", since.Value.UtcDateTime);
|
||||
}
|
||||
|
||||
if (until.HasValue)
|
||||
{
|
||||
filter &= builder.Lte("lastOccurrence", until.Value.UtcDateTime);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(continuationToken) &&
|
||||
TryParseContinuationToken(continuationToken, out var continuationTime, out var continuationId))
|
||||
{
|
||||
var lessThanTime = builder.Lt("lastOccurrence", continuationTime);
|
||||
var equalTimeLowerId = builder.And(builder.Eq("lastOccurrence", continuationTime), builder.Lte("_id", continuationId));
|
||||
filter &= builder.Or(lessThanTime, equalTimeLowerId);
|
||||
}
|
||||
|
||||
var documents = await _collection.Find(filter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Descending("lastOccurrence").Descending("_id"))
|
||||
.Limit(limit + 1)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string? nextToken = null;
|
||||
if (documents.Count > limit)
|
||||
{
|
||||
var overflow = documents[^1];
|
||||
documents.RemoveAt(documents.Count - 1);
|
||||
nextToken = BuildContinuationToken(overflow);
|
||||
}
|
||||
|
||||
var incidents = documents.Select(NotifyIncidentDocumentMapper.FromBsonDocument).ToArray();
|
||||
return new NotifyIncidentQueryResult(incidents, nextToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, incidentId));
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
|
||||
private static string BuildContinuationToken(BsonDocument document)
|
||||
{
|
||||
if (!document.TryGetValue("lastOccurrence", out var timeValue) || !timeValue.IsValidDateTime)
|
||||
{
|
||||
throw new InvalidOperationException("Incident document missing valid lastOccurrence for continuation token.");
|
||||
}
|
||||
|
||||
if (!document.TryGetValue("_id", out var idValue) || !idValue.IsString)
|
||||
{
|
||||
throw new InvalidOperationException("Incident document missing string _id for continuation token.");
|
||||
}
|
||||
|
||||
return FormattableString.Invariant($"{timeValue.ToUniversalTime():O}|{idValue.AsString}");
|
||||
}
|
||||
|
||||
private static bool TryParseContinuationToken(string token, out DateTime time, out string id)
|
||||
{
|
||||
time = default;
|
||||
id = string.Empty;
|
||||
|
||||
var parts = token.Split('|', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParseExact(parts[0], "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedTime))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
time = parsedTime.ToUniversalTime();
|
||||
id = parts[1];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB implementation of the localization bundle repository.
|
||||
/// </summary>
|
||||
internal sealed class NotifyLocalizationRepository : INotifyLocalizationRepository
|
||||
{
|
||||
private readonly NotifyMongoContext _context;
|
||||
private readonly string _collectionName;
|
||||
|
||||
public NotifyLocalizationRepository(NotifyMongoContext context, NotifyMongoOptions options)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_collectionName = options?.LocalizationCollection ?? "localization";
|
||||
}
|
||||
|
||||
private IMongoCollection<BsonDocument> GetCollection()
|
||||
=> _context.Database.GetCollection<BsonDocument>(_collectionName);
|
||||
|
||||
public async Task<NotifyLocalizationBundle?> GetAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("bundleId", bundleId));
|
||||
|
||||
var doc = await GetCollection().Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc is null ? null : ToBundle(doc);
|
||||
}
|
||||
|
||||
public async Task<NotifyLocalizationBundle?> GetByKeyAndLocaleAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(locale);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("bundleKey", bundleKey),
|
||||
Builders<BsonDocument>.Filter.Eq("locale", locale.ToLowerInvariant()));
|
||||
|
||||
var doc = await GetCollection().Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc is null ? null : ToBundle(doc);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyLocalizationBundle>> ListAsync(
|
||||
string tenantId,
|
||||
string? bundleKey = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var filterBuilder = Builders<BsonDocument>.Filter;
|
||||
var filters = new List<FilterDefinition<BsonDocument>>
|
||||
{
|
||||
filterBuilder.Eq("tenantId", tenantId)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bundleKey))
|
||||
{
|
||||
filters.Add(filterBuilder.Eq("bundleKey", bundleKey));
|
||||
}
|
||||
|
||||
var filter = filterBuilder.And(filters);
|
||||
var docs = await GetCollection().Find(filter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Ascending("bundleKey").Ascending("locale"))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return docs.Select(ToBundle).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> ListLocalesAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("bundleKey", bundleKey));
|
||||
|
||||
var locales = await GetCollection()
|
||||
.Distinct<string>("locale", filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return locales.OrderBy(l => l).ToArray();
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(
|
||||
NotifyLocalizationBundle bundle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var doc = ToBsonDocument(bundle);
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", bundle.TenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("bundleId", bundle.BundleId));
|
||||
|
||||
await GetCollection().ReplaceOneAsync(
|
||||
filter,
|
||||
doc,
|
||||
new ReplaceOptions { IsUpsert = true },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("bundleId", bundleId));
|
||||
|
||||
await GetCollection().DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyLocalizationBundle?> GetDefaultAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Eq("bundleKey", bundleKey),
|
||||
Builders<BsonDocument>.Filter.Eq("isDefault", true));
|
||||
|
||||
var doc = await GetCollection().Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc is null ? null : ToBundle(doc);
|
||||
}
|
||||
|
||||
private static BsonDocument ToBsonDocument(NotifyLocalizationBundle bundle)
|
||||
{
|
||||
var stringsDoc = new BsonDocument();
|
||||
foreach (var (key, value) in bundle.Strings)
|
||||
{
|
||||
stringsDoc[key] = value;
|
||||
}
|
||||
|
||||
var metadataDoc = new BsonDocument();
|
||||
foreach (var (key, value) in bundle.Metadata)
|
||||
{
|
||||
metadataDoc[key] = value;
|
||||
}
|
||||
|
||||
return new BsonDocument
|
||||
{
|
||||
["bundleId"] = bundle.BundleId,
|
||||
["tenantId"] = bundle.TenantId,
|
||||
["locale"] = bundle.Locale,
|
||||
["bundleKey"] = bundle.BundleKey,
|
||||
["strings"] = stringsDoc,
|
||||
["isDefault"] = bundle.IsDefault,
|
||||
["parentLocale"] = bundle.ParentLocale is not null ? (BsonValue)bundle.ParentLocale : BsonNull.Value,
|
||||
["description"] = bundle.Description is not null ? (BsonValue)bundle.Description : BsonNull.Value,
|
||||
["metadata"] = metadataDoc,
|
||||
["createdBy"] = bundle.CreatedBy is not null ? (BsonValue)bundle.CreatedBy : BsonNull.Value,
|
||||
["createdAt"] = bundle.CreatedAt.UtcDateTime,
|
||||
["updatedBy"] = bundle.UpdatedBy is not null ? (BsonValue)bundle.UpdatedBy : BsonNull.Value,
|
||||
["updatedAt"] = bundle.UpdatedAt.UtcDateTime
|
||||
};
|
||||
}
|
||||
|
||||
private static NotifyLocalizationBundle ToBundle(BsonDocument doc)
|
||||
{
|
||||
var stringsDoc = doc.GetValue("strings", new BsonDocument()).AsBsonDocument;
|
||||
var strings = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var element in stringsDoc)
|
||||
{
|
||||
strings[element.Name] = element.Value.AsString;
|
||||
}
|
||||
|
||||
var metadataDoc = doc.GetValue("metadata", new BsonDocument()).AsBsonDocument;
|
||||
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var element in metadataDoc)
|
||||
{
|
||||
metadata[element.Name] = element.Value.AsString;
|
||||
}
|
||||
|
||||
return new NotifyLocalizationBundle(
|
||||
bundleId: doc["bundleId"].AsString,
|
||||
tenantId: doc["tenantId"].AsString,
|
||||
locale: doc["locale"].AsString,
|
||||
bundleKey: doc["bundleKey"].AsString,
|
||||
strings: strings.ToImmutable(),
|
||||
isDefault: doc.GetValue("isDefault", false).AsBoolean,
|
||||
parentLocale: doc.GetValue("parentLocale", BsonNull.Value).IsBsonNull ? null : doc["parentLocale"].AsString,
|
||||
description: doc.GetValue("description", BsonNull.Value).IsBsonNull ? null : doc["description"].AsString,
|
||||
metadata: metadata.ToImmutable(),
|
||||
createdBy: doc.GetValue("createdBy", BsonNull.Value).IsBsonNull ? null : doc["createdBy"].AsString,
|
||||
createdAt: new DateTimeOffset(doc.GetValue("createdAt", DateTime.UtcNow).ToUniversalTime(), TimeSpan.Zero),
|
||||
updatedBy: doc.GetValue("updatedBy", BsonNull.Value).IsBsonNull ? null : doc["updatedBy"].AsString,
|
||||
updatedAt: new DateTimeOffset(doc.GetValue("updatedAt", DateTime.UtcNow).ToUniversalTime(), TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyLockRepository : INotifyLockRepository
|
||||
{
|
||||
private readonly IMongoCollection<NotifyLockDocument> _collection;
|
||||
|
||||
public NotifyLockRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<NotifyLockDocument>(context.Options.LocksCollection);
|
||||
}
|
||||
|
||||
public async Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var document = new NotifyLockDocument
|
||||
{
|
||||
Id = CreateDocumentId(tenantId, resource),
|
||||
TenantId = tenantId,
|
||||
Resource = resource,
|
||||
Owner = owner,
|
||||
AcquiredAt = now,
|
||||
ExpiresAt = now.Add(ttl)
|
||||
};
|
||||
|
||||
var candidateFilter = Builders<NotifyLockDocument>.Filter.Eq(x => x.Id, document.Id);
|
||||
var takeoverFilter = candidateFilter & Builders<NotifyLockDocument>.Filter.Lt(x => x.ExpiresAt, now.UtcDateTime);
|
||||
var sameOwnerFilter = candidateFilter & Builders<NotifyLockDocument>.Filter.Eq(x => x.Owner, owner);
|
||||
|
||||
var update = Builders<NotifyLockDocument>.Update
|
||||
.Set(x => x.TenantId, document.TenantId)
|
||||
.Set(x => x.Resource, document.Resource)
|
||||
.Set(x => x.Owner, document.Owner)
|
||||
.Set(x => x.AcquiredAt, document.AcquiredAt)
|
||||
.Set(x => x.ExpiresAt, document.ExpiresAt);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _collection.UpdateOneAsync(
|
||||
takeoverFilter | sameOwnerFilter,
|
||||
update.SetOnInsert(x => x.Id, document.Id),
|
||||
new UpdateOptions { IsUpsert = true },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result.MatchedCount > 0 || result.UpsertedId != null;
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<NotifyLockDocument>.Filter.Eq(x => x.Id, CreateDocumentId(tenantId, resource))
|
||||
& Builders<NotifyLockDocument>.Filter.Eq(x => x.Owner, owner);
|
||||
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyMaintenanceWindowRepository : INotifyMaintenanceWindowRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public NotifyMaintenanceWindowRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.MaintenanceWindowsCollection);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyMaintenanceWindow window, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(window);
|
||||
var document = ToBsonDocument(window);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(window.TenantId, window.WindowId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyMaintenanceWindow?> GetAsync(string tenantId, string windowId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, windowId))
|
||||
& NotDeletedFilter();
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
|
||||
string tenantId,
|
||||
bool? activeOnly = null,
|
||||
DateTimeOffset? asOf = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
|
||||
|
||||
if (activeOnly == true && asOf.HasValue)
|
||||
{
|
||||
var now = asOf.Value.UtcDateTime;
|
||||
filter &= Builders<BsonDocument>.Filter.Lte("startsAt", now)
|
||||
& Builders<BsonDocument>.Filter.Gt("endsAt", now)
|
||||
& Builders<BsonDocument>.Filter.Eq("suppressNotifications", true);
|
||||
}
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyMaintenanceWindow>> GetActiveAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ListAsync(tenantId, activeOnly: true, asOf: asOf, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string windowId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, windowId));
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update
|
||||
.Set("deletedAt", DateTime.UtcNow)
|
||||
.Set("suppressNotifications", false),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static FilterDefinition<BsonDocument> NotDeletedFilter()
|
||||
=> Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
|
||||
private static BsonDocument ToBsonDocument(NotifyMaintenanceWindow window)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(window, JsonOptions);
|
||||
var document = BsonDocument.Parse(json);
|
||||
document["_id"] = BsonValue.Create(CreateDocumentId(window.TenantId, window.WindowId));
|
||||
// Convert DateTimeOffset to DateTime for Mongo indexing
|
||||
document["startsAt"] = window.StartsAt.UtcDateTime;
|
||||
document["endsAt"] = window.EndsAt.UtcDateTime;
|
||||
return document;
|
||||
}
|
||||
|
||||
private static NotifyMaintenanceWindow FromBsonDocument(BsonDocument document)
|
||||
{
|
||||
var startsAt = document["startsAt"].ToUniversalTime();
|
||||
var endsAt = document["endsAt"].ToUniversalTime();
|
||||
|
||||
return NotifyMaintenanceWindow.Create(
|
||||
windowId: document["windowId"].AsString,
|
||||
tenantId: document["tenantId"].AsString,
|
||||
name: document["name"].AsString,
|
||||
startsAt: new DateTimeOffset(startsAt, TimeSpan.Zero),
|
||||
endsAt: new DateTimeOffset(endsAt, TimeSpan.Zero),
|
||||
suppressNotifications: document.Contains("suppressNotifications") ? document["suppressNotifications"].AsBoolean : true,
|
||||
reason: document.Contains("reason") && document["reason"] != BsonNull.Value ? document["reason"].AsString : null,
|
||||
channelIds: ExtractStringArray(document, "channelIds"),
|
||||
ruleIds: ExtractStringArray(document, "ruleIds"),
|
||||
metadata: ExtractStringDictionary(document, "metadata"),
|
||||
createdBy: document.Contains("createdBy") && document["createdBy"] != BsonNull.Value ? document["createdBy"].AsString : null,
|
||||
createdAt: document.Contains("createdAt") ? DateTimeOffset.Parse(document["createdAt"].AsString) : null,
|
||||
updatedBy: document.Contains("updatedBy") && document["updatedBy"] != BsonNull.Value ? document["updatedBy"].AsString : null,
|
||||
updatedAt: document.Contains("updatedAt") ? DateTimeOffset.Parse(document["updatedAt"].AsString) : null);
|
||||
}
|
||||
|
||||
private static IEnumerable<string>? ExtractStringArray(BsonDocument document, string key)
|
||||
{
|
||||
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonArray)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return document[key].AsBsonArray.Select(v => v.AsString);
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>>? ExtractStringDictionary(BsonDocument document, string key)
|
||||
{
|
||||
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonDocument)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = document[key].AsBsonDocument;
|
||||
return dict.Elements.Select(e => new KeyValuePair<string, string>(e.Name, e.Value.AsString));
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Collections.Immutable;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB implementation of on-call schedule repository.
|
||||
/// </summary>
|
||||
internal sealed class NotifyOnCallScheduleRepository : INotifyOnCallScheduleRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public NotifyOnCallScheduleRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.OnCallSchedulesCollection);
|
||||
}
|
||||
|
||||
public async Task<NotifyOnCallSchedule?> GetAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId))
|
||||
& NotDeletedFilter();
|
||||
|
||||
var doc = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc is null ? null : FromBsonDocument(doc);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
|
||||
string tenantId,
|
||||
bool? enabledOnly = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
|
||||
|
||||
if (enabledOnly == true)
|
||||
{
|
||||
filter &= Builders<BsonDocument>.Filter.Eq("enabled", true);
|
||||
}
|
||||
|
||||
var docs = await _collection.Find(filter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Ascending("name"))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return docs.Select(FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(
|
||||
NotifyOnCallSchedule schedule,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schedule);
|
||||
var doc = ToBsonDocument(schedule);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(schedule.TenantId, schedule.ScheduleId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task AddOverrideAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
NotifyOnCallOverride override_,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId));
|
||||
|
||||
var overrideDoc = OverrideToBson(override_);
|
||||
|
||||
var update = Builders<BsonDocument>.Update
|
||||
.Push("overrides", overrideDoc)
|
||||
.Set("updatedAt", DateTime.UtcNow);
|
||||
|
||||
await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RemoveOverrideAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
string overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId));
|
||||
|
||||
var update = Builders<BsonDocument>.Update
|
||||
.PullFilter("overrides", Builders<BsonDocument>.Filter.Eq("overrideId", overrideId))
|
||||
.Set("updatedAt", DateTime.UtcNow);
|
||||
|
||||
await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId));
|
||||
await _collection.UpdateOneAsync(
|
||||
filter,
|
||||
Builders<BsonDocument>.Update
|
||||
.Set("deletedAt", DateTime.UtcNow)
|
||||
.Set("enabled", false),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static FilterDefinition<BsonDocument> NotDeletedFilter()
|
||||
=> Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
|
||||
private static BsonDocument ToBsonDocument(NotifyOnCallSchedule schedule)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(schedule, JsonOptions);
|
||||
var document = BsonDocument.Parse(json);
|
||||
document["_id"] = CreateDocumentId(schedule.TenantId, schedule.ScheduleId);
|
||||
|
||||
// Convert layer rotationInterval to ticks for storage
|
||||
if (document.Contains("layers") && document["layers"].IsBsonArray)
|
||||
{
|
||||
foreach (var layer in document["layers"].AsBsonArray)
|
||||
{
|
||||
if (layer.IsBsonDocument && layer.AsBsonDocument.Contains("rotationInterval"))
|
||||
{
|
||||
var interval = layer.AsBsonDocument["rotationInterval"].AsString;
|
||||
if (TimeSpan.TryParse(interval, out var ts))
|
||||
{
|
||||
layer.AsBsonDocument["rotationIntervalTicks"] = ts.Ticks;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static BsonDocument OverrideToBson(NotifyOnCallOverride override_)
|
||||
{
|
||||
var doc = new BsonDocument
|
||||
{
|
||||
["overrideId"] = override_.OverrideId,
|
||||
["userId"] = override_.UserId,
|
||||
["startsAt"] = override_.StartsAt.UtcDateTime,
|
||||
["endsAt"] = override_.EndsAt.UtcDateTime,
|
||||
["createdAt"] = override_.CreatedAt.UtcDateTime
|
||||
};
|
||||
|
||||
if (override_.Reason is not null)
|
||||
doc["reason"] = override_.Reason;
|
||||
|
||||
if (override_.CreatedBy is not null)
|
||||
doc["createdBy"] = override_.CreatedBy;
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static NotifyOnCallSchedule FromBsonDocument(BsonDocument doc)
|
||||
{
|
||||
var layers = new List<NotifyOnCallLayer>();
|
||||
if (doc.Contains("layers") && doc["layers"].IsBsonArray)
|
||||
{
|
||||
foreach (var layerVal in doc["layers"].AsBsonArray)
|
||||
{
|
||||
if (layerVal.IsBsonDocument)
|
||||
{
|
||||
layers.Add(LayerFromBson(layerVal.AsBsonDocument));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var overrides = new List<NotifyOnCallOverride>();
|
||||
if (doc.Contains("overrides") && doc["overrides"].IsBsonArray)
|
||||
{
|
||||
foreach (var overrideVal in doc["overrides"].AsBsonArray)
|
||||
{
|
||||
if (overrideVal.IsBsonDocument)
|
||||
{
|
||||
overrides.Add(OverrideFromBson(overrideVal.AsBsonDocument));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var metadata = ExtractStringDictionary(doc, "metadata");
|
||||
|
||||
return NotifyOnCallSchedule.Create(
|
||||
scheduleId: doc["scheduleId"].AsString,
|
||||
tenantId: doc["tenantId"].AsString,
|
||||
name: doc["name"].AsString,
|
||||
timeZone: doc["timeZone"].AsString,
|
||||
layers: layers,
|
||||
overrides: overrides,
|
||||
enabled: doc.Contains("enabled") ? doc["enabled"].AsBoolean : true,
|
||||
description: doc.Contains("description") && doc["description"] != BsonNull.Value ? doc["description"].AsString : null,
|
||||
metadata: metadata,
|
||||
createdBy: doc.Contains("createdBy") && doc["createdBy"] != BsonNull.Value ? doc["createdBy"].AsString : null,
|
||||
createdAt: doc.Contains("createdAt") ? DateTimeOffset.Parse(doc["createdAt"].AsString) : null,
|
||||
updatedBy: doc.Contains("updatedBy") && doc["updatedBy"] != BsonNull.Value ? doc["updatedBy"].AsString : null,
|
||||
updatedAt: doc.Contains("updatedAt") ? DateTimeOffset.Parse(doc["updatedAt"].AsString) : null);
|
||||
}
|
||||
|
||||
private static NotifyOnCallLayer LayerFromBson(BsonDocument doc)
|
||||
{
|
||||
var rotationInterval = doc.Contains("rotationIntervalTicks")
|
||||
? TimeSpan.FromTicks(doc["rotationIntervalTicks"].AsInt64)
|
||||
: TimeSpan.FromDays(7);
|
||||
|
||||
var participants = new List<NotifyOnCallParticipant>();
|
||||
if (doc.Contains("participants") && doc["participants"].IsBsonArray)
|
||||
{
|
||||
foreach (var pVal in doc["participants"].AsBsonArray)
|
||||
{
|
||||
if (pVal.IsBsonDocument)
|
||||
{
|
||||
var pd = pVal.AsBsonDocument;
|
||||
|
||||
var contactMethods = new List<NotifyContactMethod>();
|
||||
if (pd.Contains("contactMethods") && pd["contactMethods"].IsBsonArray)
|
||||
{
|
||||
foreach (var cmVal in pd["contactMethods"].AsBsonArray)
|
||||
{
|
||||
if (cmVal.IsBsonDocument)
|
||||
{
|
||||
var cmd = cmVal.AsBsonDocument;
|
||||
contactMethods.Add(new NotifyContactMethod(
|
||||
Enum.Parse<NotifyContactMethodType>(cmd["type"].AsString),
|
||||
cmd["address"].AsString,
|
||||
cmd.Contains("priority") ? cmd["priority"].AsInt32 : 0,
|
||||
cmd.Contains("enabled") ? cmd["enabled"].AsBoolean : true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
participants.Add(NotifyOnCallParticipant.Create(
|
||||
userId: pd["userId"].AsString,
|
||||
name: pd.Contains("name") && pd["name"] != BsonNull.Value ? pd["name"].AsString : null,
|
||||
email: pd.Contains("email") && pd["email"] != BsonNull.Value ? pd["email"].AsString : null,
|
||||
phone: pd.Contains("phone") && pd["phone"] != BsonNull.Value ? pd["phone"].AsString : null,
|
||||
contactMethods: contactMethods));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NotifyOnCallRestriction? restrictions = null;
|
||||
if (doc.Contains("restrictions") && doc["restrictions"] != BsonNull.Value && doc["restrictions"].IsBsonDocument)
|
||||
{
|
||||
var rd = doc["restrictions"].AsBsonDocument;
|
||||
var timeRanges = new List<NotifyTimeRange>();
|
||||
|
||||
if (rd.Contains("timeRanges") && rd["timeRanges"].IsBsonArray)
|
||||
{
|
||||
foreach (var trVal in rd["timeRanges"].AsBsonArray)
|
||||
{
|
||||
if (trVal.IsBsonDocument)
|
||||
{
|
||||
var trd = trVal.AsBsonDocument;
|
||||
timeRanges.Add(new NotifyTimeRange(
|
||||
dayOfWeek: trd.Contains("dayOfWeek") && trd["dayOfWeek"] != BsonNull.Value
|
||||
? Enum.Parse<DayOfWeek>(trd["dayOfWeek"].AsString)
|
||||
: null,
|
||||
startTime: TimeOnly.Parse(trd["startTime"].AsString),
|
||||
endTime: TimeOnly.Parse(trd["endTime"].AsString)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restrictions = NotifyOnCallRestriction.Create(
|
||||
Enum.Parse<NotifyRestrictionType>(rd["type"].AsString),
|
||||
timeRanges);
|
||||
}
|
||||
|
||||
return NotifyOnCallLayer.Create(
|
||||
layerId: doc["layerId"].AsString,
|
||||
name: doc["name"].AsString,
|
||||
priority: doc["priority"].AsInt32,
|
||||
rotationType: Enum.Parse<NotifyRotationType>(doc["rotationType"].AsString),
|
||||
rotationInterval: rotationInterval,
|
||||
rotationStartsAt: DateTimeOffset.Parse(doc["rotationStartsAt"].AsString),
|
||||
participants: participants,
|
||||
restrictions: restrictions);
|
||||
}
|
||||
|
||||
private static NotifyOnCallOverride OverrideFromBson(BsonDocument doc)
|
||||
{
|
||||
return NotifyOnCallOverride.Create(
|
||||
overrideId: doc["overrideId"].AsString,
|
||||
userId: doc["userId"].AsString,
|
||||
startsAt: new DateTimeOffset(doc["startsAt"].ToUniversalTime(), TimeSpan.Zero),
|
||||
endsAt: new DateTimeOffset(doc["endsAt"].ToUniversalTime(), TimeSpan.Zero),
|
||||
reason: doc.Contains("reason") && doc["reason"] != BsonNull.Value ? doc["reason"].AsString : null,
|
||||
createdBy: doc.Contains("createdBy") && doc["createdBy"] != BsonNull.Value ? doc["createdBy"].AsString : null,
|
||||
createdAt: doc.Contains("createdAt")
|
||||
? new DateTimeOffset(doc["createdAt"].ToUniversalTime(), TimeSpan.Zero)
|
||||
: null);
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>>? ExtractStringDictionary(BsonDocument document, string key)
|
||||
{
|
||||
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonDocument)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = document[key].AsBsonDocument;
|
||||
return dict.Elements.Select(e => new KeyValuePair<string, string>(e.Name, e.Value.AsString));
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> $"{tenantId}:{resourceId}";
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyOperatorOverrideRepository : INotifyOperatorOverrideRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public NotifyOperatorOverrideRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.OperatorOverridesCollection);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@override);
|
||||
var document = ToBsonDocument(@override);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(@override.TenantId, @override.OverrideId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, overrideId))
|
||||
& NotDeletedFilter();
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyOperatorOverride>> ListActiveAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset asOf,
|
||||
NotifyOverrideType? overrideType = null,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
& Builders<BsonDocument>.Filter.Gt("expiresAt", asOf.UtcDateTime)
|
||||
& NotDeletedFilter();
|
||||
|
||||
if (overrideType.HasValue)
|
||||
{
|
||||
filter &= Builders<BsonDocument>.Filter.Eq("overrideType", overrideType.Value.ToString());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelId))
|
||||
{
|
||||
filter &= Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Eq("channelId", channelId),
|
||||
Builders<BsonDocument>.Filter.Eq("channelId", BsonNull.Value),
|
||||
Builders<BsonDocument>.Filter.Exists("channelId", false));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ruleId))
|
||||
{
|
||||
filter &= Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Eq("ruleId", ruleId),
|
||||
Builders<BsonDocument>.Filter.Eq("ruleId", BsonNull.Value),
|
||||
Builders<BsonDocument>.Filter.Exists("ruleId", false));
|
||||
}
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(
|
||||
string tenantId,
|
||||
bool? activeOnly = null,
|
||||
DateTimeOffset? asOf = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
|
||||
|
||||
if (activeOnly == true && asOf.HasValue)
|
||||
{
|
||||
filter &= Builders<BsonDocument>.Filter.Gt("expiresAt", asOf.Value.UtcDateTime);
|
||||
}
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, overrideId));
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(string tenantId, DateTimeOffset olderThan, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
& Builders<BsonDocument>.Filter.Lt("expiresAt", olderThan.UtcDateTime)
|
||||
& NotDeletedFilter();
|
||||
|
||||
await _collection.UpdateManyAsync(filter,
|
||||
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static FilterDefinition<BsonDocument> NotDeletedFilter()
|
||||
=> Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
|
||||
private static BsonDocument ToBsonDocument(NotifyOperatorOverride @override)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(@override, JsonOptions);
|
||||
var document = BsonDocument.Parse(json);
|
||||
document["_id"] = BsonValue.Create(CreateDocumentId(@override.TenantId, @override.OverrideId));
|
||||
// Convert DateTimeOffset to DateTime for Mongo indexing
|
||||
document["expiresAt"] = @override.ExpiresAt.UtcDateTime;
|
||||
return document;
|
||||
}
|
||||
|
||||
private static NotifyOperatorOverride FromBsonDocument(BsonDocument document)
|
||||
{
|
||||
var expiresAt = document["expiresAt"].ToUniversalTime();
|
||||
var overrideTypeStr = document["overrideType"].AsString;
|
||||
var overrideType = Enum.Parse<NotifyOverrideType>(overrideTypeStr, ignoreCase: true);
|
||||
|
||||
return NotifyOperatorOverride.Create(
|
||||
overrideId: document["overrideId"].AsString,
|
||||
tenantId: document["tenantId"].AsString,
|
||||
overrideType: overrideType,
|
||||
expiresAt: new DateTimeOffset(expiresAt, TimeSpan.Zero),
|
||||
channelId: document.Contains("channelId") && document["channelId"] != BsonNull.Value ? document["channelId"].AsString : null,
|
||||
ruleId: document.Contains("ruleId") && document["ruleId"] != BsonNull.Value ? document["ruleId"].AsString : null,
|
||||
reason: document.Contains("reason") && document["reason"] != BsonNull.Value ? document["reason"].AsString : null,
|
||||
createdBy: document.Contains("createdBy") && document["createdBy"] != BsonNull.Value ? document["createdBy"].AsString : null,
|
||||
createdAt: document.Contains("createdAt") ? DateTimeOffset.Parse(document["createdAt"].AsString) : null);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyPackApprovalRepository : INotifyPackApprovalRepository
|
||||
{
|
||||
private readonly IMongoCollection<PackApprovalDocument> _collection;
|
||||
|
||||
public NotifyPackApprovalRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<PackApprovalDocument>(context.Options.PackApprovalsCollection);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var filter = Builders<PackApprovalDocument>.Filter.And(
|
||||
Builders<PackApprovalDocument>.Filter.Eq(x => x.TenantId, document.TenantId),
|
||||
Builders<PackApprovalDocument>.Filter.Eq(x => x.PackId, document.PackId),
|
||||
Builders<PackApprovalDocument>.Filter.Eq(x => x.EventId, document.EventId));
|
||||
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
await _collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyQuietHoursRepository : INotifyQuietHoursRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public NotifyQuietHoursRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.QuietHoursCollection);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyQuietHoursSchedule schedule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schedule);
|
||||
var document = ToBsonDocument(schedule);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(schedule.TenantId, schedule.ScheduleId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyQuietHoursSchedule?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId))
|
||||
& NotDeletedFilter();
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
|
||||
string tenantId,
|
||||
string? channelId = null,
|
||||
bool? enabledOnly = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelId))
|
||||
{
|
||||
filter &= Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Eq("channelId", channelId),
|
||||
Builders<BsonDocument>.Filter.Eq("channelId", BsonNull.Value),
|
||||
Builders<BsonDocument>.Filter.Exists("channelId", false));
|
||||
}
|
||||
|
||||
if (enabledOnly == true)
|
||||
{
|
||||
filter &= Builders<BsonDocument>.Filter.Eq("enabled", true);
|
||||
}
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListEnabledAsync(
|
||||
string tenantId,
|
||||
string? channelId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ListAsync(tenantId, channelId, enabledOnly: true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, scheduleId));
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update
|
||||
.Set("deletedAt", DateTime.UtcNow)
|
||||
.Set("enabled", false),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static FilterDefinition<BsonDocument> NotDeletedFilter()
|
||||
=> Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
|
||||
private static BsonDocument ToBsonDocument(NotifyQuietHoursSchedule schedule)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(schedule, JsonOptions);
|
||||
var document = BsonDocument.Parse(json);
|
||||
document["_id"] = BsonValue.Create(CreateDocumentId(schedule.TenantId, schedule.ScheduleId));
|
||||
// Convert Duration to Ticks for Mongo storage
|
||||
document["durationTicks"] = schedule.Duration.Ticks;
|
||||
return document;
|
||||
}
|
||||
|
||||
private static NotifyQuietHoursSchedule FromBsonDocument(BsonDocument document)
|
||||
{
|
||||
// Handle duration from ticks
|
||||
var durationTicks = document.Contains("durationTicks") ? document["durationTicks"].AsInt64 : TimeSpan.FromHours(8).Ticks;
|
||||
var duration = TimeSpan.FromTicks(durationTicks);
|
||||
|
||||
return NotifyQuietHoursSchedule.Create(
|
||||
scheduleId: document["scheduleId"].AsString,
|
||||
tenantId: document["tenantId"].AsString,
|
||||
name: document["name"].AsString,
|
||||
cronExpression: document["cronExpression"].AsString,
|
||||
duration: duration,
|
||||
timeZone: document["timeZone"].AsString,
|
||||
channelId: document.Contains("channelId") && document["channelId"] != BsonNull.Value ? document["channelId"].AsString : null,
|
||||
enabled: document.Contains("enabled") ? document["enabled"].AsBoolean : true,
|
||||
description: document.Contains("description") && document["description"] != BsonNull.Value ? document["description"].AsString : null,
|
||||
metadata: ExtractStringDictionary(document, "metadata"),
|
||||
createdBy: document.Contains("createdBy") && document["createdBy"] != BsonNull.Value ? document["createdBy"].AsString : null,
|
||||
createdAt: document.Contains("createdAt") ? DateTimeOffset.Parse(document["createdAt"].AsString) : null,
|
||||
updatedBy: document.Contains("updatedBy") && document["updatedBy"] != BsonNull.Value ? document["updatedBy"].AsString : null,
|
||||
updatedAt: document.Contains("updatedAt") ? DateTimeOffset.Parse(document["updatedAt"].AsString) : null);
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>>? ExtractStringDictionary(BsonDocument document, string key)
|
||||
{
|
||||
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonDocument)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = document[key].AsBsonDocument;
|
||||
return dict.Elements.Select(e => new KeyValuePair<string, string>(e.Name, e.Value.AsString));
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
using StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyRuleRepository : INotifyRuleRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public NotifyRuleRepository(NotifyMongoContext context, ITenantContext? tenantContext = null)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.RulesCollection);
|
||||
_tenantContext = tenantContext ?? NullTenantContext.Instance;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
_tenantContext.ValidateTenant(rule.TenantId);
|
||||
|
||||
var document = NotifyRuleDocumentMapper.ToBsonDocument(rule);
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(rule.TenantId, rule.RuleId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", rule.TenantId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and explicit tenantId check
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, ruleId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : NotifyRuleDocumentMapper.FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(NotifyRuleDocumentMapper.FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, ruleId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId));
|
||||
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update
|
||||
.Set("deletedAt", DateTime.UtcNow)
|
||||
.Set("enabled", false),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
using StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyTemplateRepository : INotifyTemplateRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public NotifyTemplateRepository(NotifyMongoContext context, ITenantContext? tenantContext = null)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.TemplatesCollection);
|
||||
_tenantContext = tenantContext ?? NullTenantContext.Instance;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
_tenantContext.ValidateTenant(template.TenantId);
|
||||
|
||||
var document = NotifyTemplateDocumentMapper.ToBsonDocument(template);
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(template.TenantId, template.TemplateId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", template.TenantId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and explicit tenantId check
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, templateId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : NotifyTemplateDocumentMapper.FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(NotifyTemplateDocumentMapper.FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, templateId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId));
|
||||
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyThrottleConfigRepository : INotifyThrottleConfigRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public NotifyThrottleConfigRepository(NotifyMongoContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.ThrottleConfigsCollection);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
var document = ToBsonDocument(config);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(config.TenantId, config.ConfigId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, configId))
|
||||
& NotDeletedFilter();
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<NotifyThrottleConfig?> GetDefaultAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
& Builders<BsonDocument>.Filter.Eq("isDefault", true)
|
||||
& NotDeletedFilter();
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : FromBsonDocument(document);
|
||||
}
|
||||
|
||||
public async Task<NotifyThrottleConfig?> GetForChannelAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// First try to find a channel-specific config
|
||||
var channelFilter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
& Builders<BsonDocument>.Filter.Eq("channelId", channelId)
|
||||
& Builders<BsonDocument>.Filter.Eq("enabled", true)
|
||||
& NotDeletedFilter();
|
||||
|
||||
var document = await _collection.Find(channelFilter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (document is not null)
|
||||
{
|
||||
return FromBsonDocument(document);
|
||||
}
|
||||
|
||||
// Fall back to default config
|
||||
return await GetDefaultAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId) & NotDeletedFilter();
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, configId));
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update
|
||||
.Set("deletedAt", DateTime.UtcNow)
|
||||
.Set("enabled", false),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static FilterDefinition<BsonDocument> NotDeletedFilter()
|
||||
=> Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
|
||||
private static BsonDocument ToBsonDocument(NotifyThrottleConfig config)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(config, JsonOptions);
|
||||
var document = BsonDocument.Parse(json);
|
||||
document["_id"] = BsonValue.Create(CreateDocumentId(config.TenantId, config.ConfigId));
|
||||
// Convert TimeSpan to ticks for Mongo storage
|
||||
document["defaultWindowTicks"] = config.DefaultWindow.Ticks;
|
||||
return document;
|
||||
}
|
||||
|
||||
private static NotifyThrottleConfig FromBsonDocument(BsonDocument document)
|
||||
{
|
||||
var defaultWindowTicks = document.Contains("defaultWindowTicks") ? document["defaultWindowTicks"].AsInt64 : TimeSpan.FromMinutes(5).Ticks;
|
||||
var defaultWindow = TimeSpan.FromTicks(defaultWindowTicks);
|
||||
|
||||
return NotifyThrottleConfig.Create(
|
||||
configId: document["configId"].AsString,
|
||||
tenantId: document["tenantId"].AsString,
|
||||
name: document["name"].AsString,
|
||||
defaultWindow: defaultWindow,
|
||||
maxNotificationsPerWindow: document.Contains("maxNotificationsPerWindow") && document["maxNotificationsPerWindow"] != BsonNull.Value
|
||||
? document["maxNotificationsPerWindow"].AsInt32 : null,
|
||||
channelId: document.Contains("channelId") && document["channelId"] != BsonNull.Value ? document["channelId"].AsString : null,
|
||||
isDefault: document.Contains("isDefault") ? document["isDefault"].AsBoolean : false,
|
||||
enabled: document.Contains("enabled") ? document["enabled"].AsBoolean : true,
|
||||
description: document.Contains("description") && document["description"] != BsonNull.Value ? document["description"].AsString : null,
|
||||
metadata: ExtractStringDictionary(document, "metadata"),
|
||||
createdBy: document.Contains("createdBy") && document["createdBy"] != BsonNull.Value ? document["createdBy"].AsString : null,
|
||||
createdAt: document.Contains("createdAt") ? DateTimeOffset.Parse(document["createdAt"].AsString) : null,
|
||||
updatedBy: document.Contains("updatedBy") && document["updatedBy"] != BsonNull.Value ? document["updatedBy"].AsString : null,
|
||||
updatedAt: document.Contains("updatedAt") ? DateTimeOffset.Parse(document["updatedAt"].AsString) : null);
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>>? ExtractStringDictionary(BsonDocument document, string key)
|
||||
{
|
||||
if (!document.Contains(key) || document[key] == BsonNull.Value || !document[key].IsBsonDocument)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = document[key].AsBsonDocument;
|
||||
return dict.Elements.Select(e => new KeyValuePair<string, string>(e.Name, e.Value.AsString));
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.IO;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
internal static class BsonDocumentJsonExtensions
|
||||
{
|
||||
public static JsonNode ToCanonicalJsonNode(this BsonDocument document, params string[] fieldsToRemove)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var clone = document.DeepClone().AsBsonDocument;
|
||||
clone.Remove("_id");
|
||||
if (fieldsToRemove is { Length: > 0 })
|
||||
{
|
||||
foreach (var field in fieldsToRemove)
|
||||
{
|
||||
clone.Remove(field);
|
||||
}
|
||||
}
|
||||
|
||||
var json = clone.ToJson(new JsonWriterSettings
|
||||
{
|
||||
OutputMode = JsonOutputMode.RelaxedExtendedJson,
|
||||
Indent = false
|
||||
});
|
||||
|
||||
var node = JsonNode.Parse(json) ?? throw new InvalidOperationException("Unable to parse BsonDocument JSON.");
|
||||
return NormalizeExtendedJson(node);
|
||||
}
|
||||
|
||||
private static JsonNode NormalizeExtendedJson(JsonNode node)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
if (TryConvertExtendedDate(obj, out var replacement))
|
||||
{
|
||||
return replacement;
|
||||
}
|
||||
|
||||
foreach (var property in obj.ToList())
|
||||
{
|
||||
if (property.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = NormalizeExtendedJson(property.Value);
|
||||
if (!ReferenceEquals(normalized, property.Value))
|
||||
{
|
||||
obj[property.Key] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (node is JsonArray array)
|
||||
{
|
||||
for (var i = 0; i < array.Count; i++)
|
||||
{
|
||||
if (array[i] is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = NormalizeExtendedJson(array[i]!);
|
||||
if (!ReferenceEquals(normalized, array[i]))
|
||||
{
|
||||
array[i] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static bool TryConvertExtendedDate(JsonObject obj, out JsonNode replacement)
|
||||
{
|
||||
replacement = obj;
|
||||
if (obj.Count != 1 || !obj.TryGetPropertyValue("$date", out var value) || value is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value is JsonValue directValue)
|
||||
{
|
||||
if (directValue.TryGetValue(out string? dateString) && TryParseIso(dateString, out var iso))
|
||||
{
|
||||
replacement = JsonValue.Create(iso);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (directValue.TryGetValue(out long epochMilliseconds))
|
||||
{
|
||||
replacement = JsonValue.Create(DateTimeOffset.FromUnixTimeMilliseconds(epochMilliseconds).ToString("O"));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (value is JsonObject nested && nested.TryGetPropertyValue("$numberLong", out var numberNode) && numberNode is JsonValue numberValue && numberValue.TryGetValue(out string? numberString) && long.TryParse(numberString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms))
|
||||
{
|
||||
replacement = JsonValue.Create(DateTimeOffset.FromUnixTimeMilliseconds(ms).ToString("O"));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseIso(string? value, out string iso)
|
||||
{
|
||||
iso = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed))
|
||||
{
|
||||
iso = parsed.ToUniversalTime().ToString("O");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
internal static class NotifyChannelDocumentMapper
|
||||
{
|
||||
public static BsonDocument ToBsonDocument(NotifyChannel channel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
var json = NotifyCanonicalJsonSerializer.Serialize(channel);
|
||||
var document = BsonDocument.Parse(json);
|
||||
document["_id"] = BsonValue.Create(CreateDocumentId(channel.TenantId, channel.ChannelId));
|
||||
return document;
|
||||
}
|
||||
|
||||
public static NotifyChannel FromBsonDocument(BsonDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var node = document.ToCanonicalJsonNode();
|
||||
return NotifySchemaMigration.UpgradeChannel(node);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
internal static class NotifyDeliveryDocumentMapper
|
||||
{
|
||||
public static BsonDocument ToBsonDocument(NotifyDelivery delivery)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(delivery);
|
||||
var json = NotifyCanonicalJsonSerializer.Serialize(delivery);
|
||||
var document = BsonDocument.Parse(json);
|
||||
document["_id"] = BsonValue.Create(CreateDocumentId(delivery.TenantId, delivery.DeliveryId));
|
||||
document["tenantId"] = delivery.TenantId;
|
||||
document["createdAt"] = delivery.CreatedAt.UtcDateTime;
|
||||
if (delivery.SentAt is not null)
|
||||
{
|
||||
document["sentAt"] = delivery.SentAt.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
if (delivery.CompletedAt is not null)
|
||||
{
|
||||
document["completedAt"] = delivery.CompletedAt.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
var sortTimestamp = delivery.CompletedAt ?? delivery.SentAt ?? delivery.CreatedAt;
|
||||
document["sortKey"] = sortTimestamp.UtcDateTime;
|
||||
return document;
|
||||
}
|
||||
|
||||
public static NotifyDelivery FromBsonDocument(BsonDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var node = document.ToCanonicalJsonNode("sortKey");
|
||||
return NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(node.ToJsonString());
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Maps IncidentState to and from BsonDocument.
|
||||
/// </summary>
|
||||
internal static class NotifyIncidentDocumentMapper
|
||||
{
|
||||
public static BsonDocument ToBsonDocument(IncidentState incident)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incident);
|
||||
|
||||
var document = new BsonDocument
|
||||
{
|
||||
["_id"] = $"{incident.TenantId}:{incident.IncidentId}",
|
||||
["incidentId"] = incident.IncidentId,
|
||||
["tenantId"] = incident.TenantId,
|
||||
["correlationKey"] = incident.CorrelationKey,
|
||||
["eventKind"] = incident.EventKind,
|
||||
["title"] = incident.Title,
|
||||
["status"] = incident.Status.ToString().ToLowerInvariant(),
|
||||
["eventCount"] = incident.EventCount,
|
||||
["firstOccurrence"] = incident.FirstOccurrence.UtcDateTime,
|
||||
["lastOccurrence"] = incident.LastOccurrence.UtcDateTime,
|
||||
["eventIds"] = new BsonArray(incident.EventIds)
|
||||
};
|
||||
|
||||
if (incident.AcknowledgedBy is not null)
|
||||
{
|
||||
document["acknowledgedBy"] = incident.AcknowledgedBy;
|
||||
}
|
||||
|
||||
if (incident.AcknowledgedAt.HasValue)
|
||||
{
|
||||
document["acknowledgedAt"] = incident.AcknowledgedAt.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
if (incident.AcknowledgeComment is not null)
|
||||
{
|
||||
document["acknowledgeComment"] = incident.AcknowledgeComment;
|
||||
}
|
||||
|
||||
if (incident.ResolvedBy is not null)
|
||||
{
|
||||
document["resolvedBy"] = incident.ResolvedBy;
|
||||
}
|
||||
|
||||
if (incident.ResolvedAt.HasValue)
|
||||
{
|
||||
document["resolvedAt"] = incident.ResolvedAt.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
if (incident.ResolutionReason is not null)
|
||||
{
|
||||
document["resolutionReason"] = incident.ResolutionReason;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static IncidentState FromBsonDocument(BsonDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var eventIds = new List<string>();
|
||||
if (document.TryGetValue("eventIds", out var eventIdsValue) && eventIdsValue.IsBsonArray)
|
||||
{
|
||||
eventIds.AddRange(eventIdsValue.AsBsonArray.Select(v => v.AsString));
|
||||
}
|
||||
|
||||
var status = ParseStatus(document.GetValue("status", "open").AsString);
|
||||
|
||||
return new IncidentState
|
||||
{
|
||||
IncidentId = document["incidentId"].AsString,
|
||||
TenantId = document["tenantId"].AsString,
|
||||
CorrelationKey = document["correlationKey"].AsString,
|
||||
EventKind = document["eventKind"].AsString,
|
||||
Title = document["title"].AsString,
|
||||
Status = status,
|
||||
EventCount = document["eventCount"].AsInt32,
|
||||
FirstOccurrence = new DateTimeOffset(document["firstOccurrence"].ToUniversalTime(), TimeSpan.Zero),
|
||||
LastOccurrence = new DateTimeOffset(document["lastOccurrence"].ToUniversalTime(), TimeSpan.Zero),
|
||||
EventIds = eventIds,
|
||||
AcknowledgedBy = document.TryGetValue("acknowledgedBy", out var ackBy) ? ackBy.AsString : null,
|
||||
AcknowledgedAt = document.TryGetValue("acknowledgedAt", out var ackAt) && ackAt.IsValidDateTime
|
||||
? new DateTimeOffset(ackAt.ToUniversalTime(), TimeSpan.Zero)
|
||||
: null,
|
||||
AcknowledgeComment = document.TryGetValue("acknowledgeComment", out var ackComment) ? ackComment.AsString : null,
|
||||
ResolvedBy = document.TryGetValue("resolvedBy", out var resBy) ? resBy.AsString : null,
|
||||
ResolvedAt = document.TryGetValue("resolvedAt", out var resAt) && resAt.IsValidDateTime
|
||||
? new DateTimeOffset(resAt.ToUniversalTime(), TimeSpan.Zero)
|
||||
: null,
|
||||
ResolutionReason = document.TryGetValue("resolutionReason", out var resReason) ? resReason.AsString : null
|
||||
};
|
||||
}
|
||||
|
||||
private static IncidentStatus ParseStatus(string status)
|
||||
{
|
||||
return status.ToLowerInvariant() switch
|
||||
{
|
||||
"open" => IncidentStatus.Open,
|
||||
"acknowledged" => IncidentStatus.Acknowledged,
|
||||
"resolved" => IncidentStatus.Resolved,
|
||||
_ => IncidentStatus.Open
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
internal static class NotifyRuleDocumentMapper
|
||||
{
|
||||
public static BsonDocument ToBsonDocument(NotifyRule rule)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
var json = NotifyCanonicalJsonSerializer.Serialize(rule);
|
||||
var document = BsonDocument.Parse(json);
|
||||
document["_id"] = BsonValue.Create(CreateDocumentId(rule.TenantId, rule.RuleId));
|
||||
return document;
|
||||
}
|
||||
|
||||
public static NotifyRule FromBsonDocument(BsonDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var node = document.ToCanonicalJsonNode();
|
||||
return NotifySchemaMigration.UpgradeRule(node);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string ruleId)
|
||||
=> string.Create(tenantId.Length + ruleId.Length + 1, (tenantId, ruleId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.ruleId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
|
||||
internal static class NotifyTemplateDocumentMapper
|
||||
{
|
||||
public static BsonDocument ToBsonDocument(NotifyTemplate template)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
var json = NotifyCanonicalJsonSerializer.Serialize(template);
|
||||
var document = BsonDocument.Parse(json);
|
||||
document["_id"] = BsonValue.Create(CreateDocumentId(template.TenantId, template.TemplateId));
|
||||
return document;
|
||||
}
|
||||
|
||||
public static NotifyTemplate FromBsonDocument(BsonDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var node = document.ToCanonicalJsonNode();
|
||||
return NotifySchemaMigration.UpgradeTemplate(node);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Migrations;
|
||||
using StellaOps.Notify.Storage.Mongo.Options;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddNotifyMongoStorage(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.Configure<NotifyMongoOptions>(configuration);
|
||||
services.AddSingleton<NotifyMongoContext>();
|
||||
services.AddSingleton<NotifyMongoMigrationRunner>();
|
||||
services.AddSingleton<INotifyMongoMigration, EnsureNotifyCollectionsMigration>();
|
||||
services.AddSingleton<INotifyMongoMigration, EnsureNotifyIndexesMigration>();
|
||||
services.AddSingleton<INotifyMongoInitializer, NotifyMongoInitializer>();
|
||||
services.AddSingleton<INotifyRuleRepository, NotifyRuleRepository>();
|
||||
services.AddSingleton<INotifyChannelRepository, NotifyChannelRepository>();
|
||||
services.AddSingleton<INotifyTemplateRepository, NotifyTemplateRepository>();
|
||||
services.AddSingleton<INotifyDeliveryRepository, NotifyDeliveryRepository>();
|
||||
services.AddSingleton<INotifyDigestRepository, NotifyDigestRepository>();
|
||||
services.AddSingleton<INotifyLockRepository, NotifyLockRepository>();
|
||||
services.AddSingleton<INotifyAuditRepository, NotifyAuditRepository>();
|
||||
services.AddSingleton<INotifyQuietHoursRepository, NotifyQuietHoursRepository>();
|
||||
services.AddSingleton<INotifyMaintenanceWindowRepository, NotifyMaintenanceWindowRepository>();
|
||||
services.AddSingleton<INotifyThrottleConfigRepository, NotifyThrottleConfigRepository>();
|
||||
services.AddSingleton<INotifyOperatorOverrideRepository, NotifyOperatorOverrideRepository>();
|
||||
services.AddSingleton<INotifyEscalationPolicyRepository, NotifyEscalationPolicyRepository>();
|
||||
services.AddSingleton<INotifyEscalationStateRepository, NotifyEscalationStateRepository>();
|
||||
services.AddSingleton<INotifyOnCallScheduleRepository, NotifyOnCallScheduleRepository>();
|
||||
services.AddSingleton<INotifyInboxRepository, NotifyInboxRepository>();
|
||||
services.AddSingleton<INotifyLocalizationRepository, NotifyLocalizationRepository>();
|
||||
services.AddSingleton<INotifyIncidentRepository, NotifyIncidentRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,145 +0,0 @@
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Provides tenant context for RLS-like tenant isolation in storage operations.
|
||||
/// </summary>
|
||||
public interface ITenantContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current authenticated tenant ID, or null if not authenticated.
|
||||
/// </summary>
|
||||
string? CurrentTenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the current context has a valid tenant.
|
||||
/// </summary>
|
||||
bool HasTenant { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the requested tenant matches the current context.
|
||||
/// Throws <see cref="TenantMismatchException"/> if validation fails.
|
||||
/// </summary>
|
||||
/// <param name="requestedTenantId">The tenant ID being requested.</param>
|
||||
/// <exception cref="TenantMismatchException">Thrown when tenants don't match.</exception>
|
||||
void ValidateTenant(string requestedTenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the current context allows access to the specified tenant.
|
||||
/// Admin tenants may access other tenants.
|
||||
/// </summary>
|
||||
bool CanAccessTenant(string targetTenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a tenant isolation violation is detected.
|
||||
/// </summary>
|
||||
public sealed class TenantMismatchException : InvalidOperationException
|
||||
{
|
||||
public string RequestedTenantId { get; }
|
||||
public string? CurrentTenantId { get; }
|
||||
|
||||
public TenantMismatchException(string requestedTenantId, string? currentTenantId)
|
||||
: base($"Tenant isolation violation: requested tenant '{requestedTenantId}' does not match current tenant '{currentTenantId ?? "(none)"}'")
|
||||
{
|
||||
RequestedTenantId = requestedTenantId;
|
||||
CurrentTenantId = currentTenantId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation that uses AsyncLocal to track tenant context.
|
||||
/// </summary>
|
||||
public sealed class DefaultTenantContext : ITenantContext
|
||||
{
|
||||
private static readonly AsyncLocal<string?> _currentTenant = new();
|
||||
private readonly HashSet<string> _adminTenants;
|
||||
|
||||
public DefaultTenantContext(IEnumerable<string>? adminTenants = null)
|
||||
{
|
||||
_adminTenants = adminTenants?.ToHashSet(StringComparer.OrdinalIgnoreCase)
|
||||
?? new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "admin", "system" };
|
||||
}
|
||||
|
||||
public string? CurrentTenantId
|
||||
{
|
||||
get => _currentTenant.Value;
|
||||
set => _currentTenant.Value = value;
|
||||
}
|
||||
|
||||
public bool HasTenant => !string.IsNullOrWhiteSpace(_currentTenant.Value);
|
||||
|
||||
public void ValidateTenant(string requestedTenantId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(requestedTenantId);
|
||||
|
||||
if (!CanAccessTenant(requestedTenantId))
|
||||
{
|
||||
throw new TenantMismatchException(requestedTenantId, CurrentTenantId);
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanAccessTenant(string targetTenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetTenantId))
|
||||
return false;
|
||||
|
||||
// No current tenant means no access
|
||||
if (!HasTenant)
|
||||
return false;
|
||||
|
||||
// Same tenant always allowed
|
||||
if (string.Equals(CurrentTenantId, targetTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Admin tenants can access other tenants
|
||||
if (_adminTenants.Contains(CurrentTenantId!))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current tenant context. Returns a disposable to restore previous value.
|
||||
/// </summary>
|
||||
public IDisposable SetTenant(string tenantId)
|
||||
{
|
||||
var previous = _currentTenant.Value;
|
||||
_currentTenant.Value = tenantId;
|
||||
return new TenantScope(previous);
|
||||
}
|
||||
|
||||
private sealed class TenantScope : IDisposable
|
||||
{
|
||||
private readonly string? _previousTenant;
|
||||
private bool _disposed;
|
||||
|
||||
public TenantScope(string? previousTenant) => _previousTenant = previousTenant;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_currentTenant.Value = _previousTenant;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation for testing or contexts without tenant isolation.
|
||||
/// </summary>
|
||||
public sealed class NullTenantContext : ITenantContext
|
||||
{
|
||||
public static readonly NullTenantContext Instance = new();
|
||||
|
||||
public string? CurrentTenantId => null;
|
||||
public bool HasTenant => false;
|
||||
|
||||
public void ValidateTenant(string requestedTenantId)
|
||||
{
|
||||
// No-op - allows all access
|
||||
}
|
||||
|
||||
public bool CanAccessTenant(string targetTenantId) => true;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for tenant-aware MongoDB repositories with RLS-like filtering.
|
||||
/// </summary>
|
||||
public abstract class TenantAwareRepository
|
||||
{
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
protected TenantAwareRepository(ITenantContext? tenantContext = null)
|
||||
{
|
||||
_tenantContext = tenantContext ?? NullTenantContext.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant context for validation.
|
||||
/// </summary>
|
||||
protected ITenantContext TenantContext => _tenantContext;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the requested tenant is accessible from the current context.
|
||||
/// </summary>
|
||||
/// <param name="requestedTenantId">The tenant ID being requested.</param>
|
||||
protected void ValidateTenantAccess(string requestedTenantId)
|
||||
{
|
||||
_tenantContext.ValidateTenant(requestedTenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a filter that includes both ID and explicit tenantId check (dual-filter pattern).
|
||||
/// This provides RLS-like defense-in-depth.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="documentId">The full document ID (typically tenant-scoped).</param>
|
||||
/// <returns>A filter requiring both ID match and tenantId match.</returns>
|
||||
protected static FilterDefinition<BsonDocument> CreateTenantSafeIdFilter(
|
||||
string tenantId,
|
||||
string documentId)
|
||||
{
|
||||
return Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", documentId),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a filter with an explicit tenantId check.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID to scope the query to.</param>
|
||||
/// <param name="baseFilter">The base filter to wrap.</param>
|
||||
/// <returns>A filter that includes the tenantId check.</returns>
|
||||
protected static FilterDefinition<BsonDocument> WithTenantScope(
|
||||
string tenantId,
|
||||
FilterDefinition<BsonDocument> baseFilter)
|
||||
{
|
||||
return Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
baseFilter
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a filter for listing documents within a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="includeDeleted">Whether to include soft-deleted documents.</param>
|
||||
/// <returns>A filter for the tenant's documents.</returns>
|
||||
protected static FilterDefinition<BsonDocument> CreateTenantListFilter(
|
||||
string tenantId,
|
||||
bool includeDeleted = false)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId);
|
||||
|
||||
if (!includeDeleted)
|
||||
{
|
||||
filter = Builders<BsonDocument>.Filter.And(
|
||||
filter,
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sort definition for common ordering patterns.
|
||||
/// </summary>
|
||||
/// <param name="sortBy">The field to sort by.</param>
|
||||
/// <param name="ascending">True for ascending, false for descending.</param>
|
||||
/// <returns>A sort definition.</returns>
|
||||
protected static SortDefinition<BsonDocument> CreateSort(string sortBy, bool ascending = true)
|
||||
{
|
||||
return ascending
|
||||
? Builders<BsonDocument>.Sort.Ascending(sortBy)
|
||||
: Builders<BsonDocument>.Sort.Descending(sortBy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a document ID using the tenant-scoped format.
|
||||
/// </summary>
|
||||
protected static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> TenantScopedId.Create(tenantId, resourceId);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for constructing tenant-scoped document IDs with consistent format.
|
||||
/// </summary>
|
||||
public static class TenantScopedId
|
||||
{
|
||||
private const char Separator = ':';
|
||||
|
||||
/// <summary>
|
||||
/// Creates a tenant-scoped ID in the format "{tenantId}:{resourceId}".
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID (required).</param>
|
||||
/// <param name="resourceId">The resource ID (required).</param>
|
||||
/// <returns>A composite ID string.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if either parameter is null or whitespace.</exception>
|
||||
public static string Create(string tenantId, string resourceId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(resourceId);
|
||||
|
||||
// Validate no separator in tenant or resource IDs to prevent injection
|
||||
if (tenantId.Contains(Separator))
|
||||
throw new ArgumentException($"Tenant ID cannot contain '{Separator}'", nameof(tenantId));
|
||||
|
||||
if (resourceId.Contains(Separator))
|
||||
throw new ArgumentException($"Resource ID cannot contain '{Separator}'", nameof(resourceId));
|
||||
|
||||
return string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = Separator;
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a tenant-scoped ID into its components.
|
||||
/// </summary>
|
||||
/// <param name="scopedId">The composite ID to parse.</param>
|
||||
/// <param name="tenantId">Output: the extracted tenant ID.</param>
|
||||
/// <param name="resourceId">Output: the extracted resource ID.</param>
|
||||
/// <returns>True if parsing succeeded, false otherwise.</returns>
|
||||
public static bool TryParse(string scopedId, out string tenantId, out string resourceId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
resourceId = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scopedId))
|
||||
return false;
|
||||
|
||||
var separatorIndex = scopedId.IndexOf(Separator);
|
||||
if (separatorIndex <= 0 || separatorIndex >= scopedId.Length - 1)
|
||||
return false;
|
||||
|
||||
tenantId = scopedId[..separatorIndex];
|
||||
resourceId = scopedId[(separatorIndex + 1)..];
|
||||
|
||||
return !string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(resourceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the tenant ID from a tenant-scoped ID.
|
||||
/// </summary>
|
||||
/// <param name="scopedId">The composite ID.</param>
|
||||
/// <returns>The tenant ID, or null if parsing failed.</returns>
|
||||
public static string? ExtractTenantId(string scopedId)
|
||||
{
|
||||
return TryParse(scopedId, out var tenantId, out _) ? tenantId : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a scoped ID belongs to the expected tenant.
|
||||
/// </summary>
|
||||
/// <param name="scopedId">The composite ID to validate.</param>
|
||||
/// <param name="expectedTenantId">The expected tenant ID.</param>
|
||||
/// <returns>True if the ID belongs to the expected tenant.</returns>
|
||||
public static bool BelongsToTenant(string scopedId, string expectedTenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scopedId) || string.IsNullOrWhiteSpace(expectedTenantId))
|
||||
return false;
|
||||
|
||||
var extractedTenant = ExtractTenantId(scopedId);
|
||||
return string.Equals(extractedTenant, expectedTenantId, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user