Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Storage.Mongo — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement Mongo persistence (rules, channels, deliveries, digests, locks, audit) per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,31 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 LocksCollection { get; set; } = "locks";
|
||||
|
||||
public string AuditCollection { get; set; } = "audit";
|
||||
|
||||
public string MigrationsCollection { get; set; } = "_notify_migrations";
|
||||
|
||||
public TimeSpan DeliveryHistoryRetention { get; set; } = TimeSpan.FromDays(90);
|
||||
|
||||
public bool UseMajorityReadConcern { get; set; } = true;
|
||||
|
||||
public bool UseMajorityWriteConcern { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Notify.Storage.Mongo.Tests")]
|
||||
@@ -0,0 +1,10 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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 NotifyChannelRepository : INotifyChannelRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
|
||||
public NotifyChannelRepository(NotifyMongoContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.ChannelsCollection);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
var document = NotifyChannelDocumentMapper.ToBsonDocument(channel);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(channel.TenantId, channel.ChannelId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, channelId))
|
||||
& 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)
|
||||
{
|
||||
var filter = 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)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, channelId));
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow).Set("enabled", false),
|
||||
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)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
public sealed record NotifyDeliveryQueryResult(IReadOnlyList<NotifyDelivery> Items, string? ContinuationToken);
|
||||
@@ -0,0 +1,179 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyRuleRepository : INotifyRuleRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
|
||||
public NotifyRuleRepository(NotifyMongoContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.RulesCollection);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
var document = NotifyRuleDocumentMapper.ToBsonDocument(rule);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(rule.TenantId, rule.RuleId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, ruleId))
|
||||
& 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)
|
||||
{
|
||||
var filter = 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)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, ruleId));
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update
|
||||
.Set("deletedAt", DateTime.UtcNow)
|
||||
.Set("enabled", false),
|
||||
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)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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 NotifyTemplateRepository : INotifyTemplateRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
|
||||
public NotifyTemplateRepository(NotifyMongoContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.TemplatesCollection);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
var document = NotifyTemplateDocumentMapper.ToBsonDocument(template);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(template.TenantId, template.TemplateId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, templateId))
|
||||
& 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)
|
||||
{
|
||||
var filter = 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)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, templateId));
|
||||
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)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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)..]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<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>
|
||||
@@ -0,0 +1,2 @@
|
||||
# Notify Storage Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — storage responsibilities now tracked in `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
Reference in New Issue
Block a user