up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-29 02:40:21 +02:00
parent 887b0a1c67
commit 7e7be4d2fd
54 changed files with 4907 additions and 3 deletions

View File

@@ -0,0 +1,30 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Digest status values.
/// </summary>
public static class DigestStatus
{
public const string Collecting = "collecting";
public const string Sending = "sending";
public const string Sent = "sent";
}
/// <summary>
/// Represents a digest of aggregated notifications.
/// </summary>
public sealed class DigestEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required Guid ChannelId { get; init; }
public required string Recipient { get; init; }
public required string DigestKey { get; init; }
public int EventCount { get; init; }
public string Events { get; init; } = "[]";
public string Status { get; init; } = DigestStatus.Collecting;
public DateTimeOffset CollectUntil { get; init; }
public DateTimeOffset? SentAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,51 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents an escalation policy.
/// </summary>
public sealed class EscalationPolicyEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public bool Enabled { get; init; } = true;
public string Steps { get; init; } = "[]";
public int RepeatCount { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Escalation state status values.
/// </summary>
public static class EscalationStatus
{
public const string Active = "active";
public const string Acknowledged = "acknowledged";
public const string Resolved = "resolved";
public const string Expired = "expired";
}
/// <summary>
/// Represents the state of an escalation.
/// </summary>
public sealed class EscalationStateEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required Guid PolicyId { get; init; }
public Guid? IncidentId { get; init; }
public required string CorrelationId { get; init; }
public int CurrentStep { get; init; }
public int RepeatIteration { get; init; }
public string Status { get; init; } = EscalationStatus.Active;
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? NextEscalationAt { get; init; }
public DateTimeOffset? AcknowledgedAt { get; init; }
public string? AcknowledgedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string? ResolvedBy { get; init; }
public string Metadata { get; init; } = "{}";
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents an in-app notification inbox item.
/// </summary>
public sealed class InboxEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required Guid UserId { get; init; }
public required string Title { get; init; }
public string? Body { get; init; }
public required string EventType { get; init; }
public string EventPayload { get; init; } = "{}";
public bool Read { get; init; }
public bool Archived { get; init; }
public string? ActionUrl { get; init; }
public string? CorrelationId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ReadAt { get; init; }
public DateTimeOffset? ArchivedAt { get; init; }
}

View File

@@ -0,0 +1,46 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Incident severity values.
/// </summary>
public static class IncidentSeverity
{
public const string Critical = "critical";
public const string High = "high";
public const string Medium = "medium";
public const string Low = "low";
}
/// <summary>
/// Incident status values.
/// </summary>
public static class IncidentStatus
{
public const string Open = "open";
public const string Acknowledged = "acknowledged";
public const string Resolved = "resolved";
public const string Closed = "closed";
}
/// <summary>
/// Represents an incident.
/// </summary>
public sealed class IncidentEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Title { get; init; }
public string? Description { get; init; }
public string Severity { get; init; } = IncidentSeverity.Medium;
public string Status { get; init; } = IncidentStatus.Open;
public string? Source { get; init; }
public string? CorrelationId { get; init; }
public Guid? AssignedTo { get; init; }
public Guid? EscalationPolicyId { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? AcknowledgedAt { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public DateTimeOffset? ClosedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents a maintenance window for suppressing notifications.
/// </summary>
public sealed class MaintenanceWindowEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public DateTimeOffset StartAt { get; init; }
public DateTimeOffset EndAt { get; init; }
public Guid[]? SuppressChannels { get; init; }
public string[]? SuppressEventTypes { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents an audit log entry for the notify module.
/// </summary>
public sealed class NotifyAuditEntity
{
public long Id { get; init; }
public required string TenantId { get; init; }
public Guid? UserId { get; init; }
public required string Action { get; init; }
public required string ResourceType { get; init; }
public string? ResourceId { get; init; }
public string? Details { get; init; }
public string? CorrelationId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,29 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Rotation type values.
/// </summary>
public static class RotationType
{
public const string Daily = "daily";
public const string Weekly = "weekly";
public const string Custom = "custom";
}
/// <summary>
/// Represents an on-call schedule.
/// </summary>
public sealed class OnCallScheduleEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public string Timezone { get; init; } = "UTC";
public string RotationType { get; init; } = Models.RotationType.Weekly;
public string Participants { get; init; } = "[]";
public string Overrides { get; init; } = "[]";
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents quiet hours configuration.
/// </summary>
public sealed class QuietHoursEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid? UserId { get; init; }
public Guid? ChannelId { get; init; }
public required TimeOnly StartTime { get; init; }
public required TimeOnly EndTime { get; init; }
public string Timezone { get; init; } = "UTC";
public int[] DaysOfWeek { get; init; } = [0, 1, 2, 3, 4, 5, 6];
public bool Enabled { get; init; } = true;
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,21 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents a notification routing rule.
/// </summary>
public sealed class RuleEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public bool Enabled { get; init; } = true;
public int Priority { get; init; }
public string[] EventTypes { get; init; } = [];
public string Filter { get; init; } = "{}";
public Guid[] ChannelIds { get; init; } = [];
public Guid? TemplateId { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents a notification template.
/// </summary>
public sealed class TemplateEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public required ChannelType ChannelType { get; init; }
public string? SubjectTemplate { get; init; }
public required string BodyTemplate { get; init; }
public string Locale { get; init; } = "en";
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,142 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class DigestRepository : RepositoryBase<NotifyDataSource>, IDigestRepository
{
public DigestRepository(NotifyDataSource dataSource, ILogger<DigestRepository> logger)
: base(dataSource, logger) { }
public async Task<DigestEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until, sent_at, created_at, updated_at
FROM notify.digests WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapDigest, cancellationToken).ConfigureAwait(false);
}
public async Task<DigestEntity?> GetByKeyAsync(string tenantId, Guid channelId, string recipient, string digestKey, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until, sent_at, created_at, updated_at
FROM notify.digests WHERE tenant_id = @tenant_id AND channel_id = @channel_id AND recipient = @recipient AND digest_key = @digest_key
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "channel_id", channelId);
AddParameter(cmd, "recipient", recipient);
AddParameter(cmd, "digest_key", digestKey);
}, MapDigest, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<DigestEntity>> GetReadyToSendAsync(int limit = 100, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until, sent_at, created_at, updated_at
FROM notify.digests WHERE status = 'collecting' AND collect_until <= NOW()
ORDER BY collect_until LIMIT @limit
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "limit", limit);
var results = new List<DigestEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(MapDigest(reader));
return results;
}
public async Task<DigestEntity> UpsertAsync(DigestEntity digest, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.digests (id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until)
VALUES (@id, @tenant_id, @channel_id, @recipient, @digest_key, @event_count, @events::jsonb, @status, @collect_until)
ON CONFLICT (tenant_id, channel_id, recipient, digest_key) DO UPDATE SET
event_count = notify.digests.event_count + EXCLUDED.event_count,
events = notify.digests.events || EXCLUDED.events,
collect_until = GREATEST(notify.digests.collect_until, EXCLUDED.collect_until)
RETURNING *
""";
var id = digest.Id == Guid.Empty ? Guid.NewGuid() : digest.Id;
await using var connection = await DataSource.OpenConnectionAsync(digest.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", digest.TenantId);
AddParameter(command, "channel_id", digest.ChannelId);
AddParameter(command, "recipient", digest.Recipient);
AddParameter(command, "digest_key", digest.DigestKey);
AddParameter(command, "event_count", digest.EventCount);
AddJsonbParameter(command, "events", digest.Events);
AddParameter(command, "status", digest.Status);
AddParameter(command, "collect_until", digest.CollectUntil);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapDigest(reader);
}
public async Task<bool> AddEventAsync(string tenantId, Guid id, string eventJson, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.digests SET event_count = event_count + 1, events = events || @event::jsonb
WHERE tenant_id = @tenant_id AND id = @id AND status = 'collecting'
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddJsonbParameter(cmd, "event", eventJson);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> MarkSendingAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.digests SET status = 'sending' WHERE tenant_id = @tenant_id AND id = @id AND status = 'collecting'";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> MarkSentAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.digests SET status = 'sent', sent_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.digests WHERE status = 'sent' AND sent_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static DigestEntity MapDigest(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
ChannelId = reader.GetGuid(2),
Recipient = reader.GetString(3),
DigestKey = reader.GetString(4),
EventCount = reader.GetInt32(5),
Events = reader.GetString(6),
Status = reader.GetString(7),
CollectUntil = reader.GetFieldValue<DateTimeOffset>(8),
SentAt = GetNullableDateTimeOffset(reader, 9),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(11)
};
}

View File

@@ -0,0 +1,252 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class EscalationPolicyRepository : RepositoryBase<NotifyDataSource>, IEscalationPolicyRepository
{
public EscalationPolicyRepository(NotifyDataSource dataSource, ILogger<EscalationPolicyRepository> logger)
: base(dataSource, logger) { }
public async Task<EscalationPolicyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, steps, repeat_count, metadata, created_at, updated_at
FROM notify.escalation_policies WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapPolicy, cancellationToken).ConfigureAwait(false);
}
public async Task<EscalationPolicyEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, steps, repeat_count, metadata, created_at, updated_at
FROM notify.escalation_policies WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapPolicy, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<EscalationPolicyEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, steps, repeat_count, metadata, created_at, updated_at
FROM notify.escalation_policies WHERE tenant_id = @tenant_id ORDER BY name
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapPolicy, cancellationToken).ConfigureAwait(false);
}
public async Task<EscalationPolicyEntity> CreateAsync(EscalationPolicyEntity policy, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.escalation_policies (id, tenant_id, name, description, enabled, steps, repeat_count, metadata)
VALUES (@id, @tenant_id, @name, @description, @enabled, @steps::jsonb, @repeat_count, @metadata::jsonb)
RETURNING *
""";
var id = policy.Id == Guid.Empty ? Guid.NewGuid() : policy.Id;
await using var connection = await DataSource.OpenConnectionAsync(policy.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", policy.TenantId);
AddParameter(command, "name", policy.Name);
AddParameter(command, "description", policy.Description);
AddParameter(command, "enabled", policy.Enabled);
AddJsonbParameter(command, "steps", policy.Steps);
AddParameter(command, "repeat_count", policy.RepeatCount);
AddJsonbParameter(command, "metadata", policy.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapPolicy(reader);
}
public async Task<bool> UpdateAsync(EscalationPolicyEntity policy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_policies SET name = @name, description = @description, enabled = @enabled,
steps = @steps::jsonb, repeat_count = @repeat_count, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(policy.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", policy.TenantId);
AddParameter(cmd, "id", policy.Id);
AddParameter(cmd, "name", policy.Name);
AddParameter(cmd, "description", policy.Description);
AddParameter(cmd, "enabled", policy.Enabled);
AddJsonbParameter(cmd, "steps", policy.Steps);
AddParameter(cmd, "repeat_count", policy.RepeatCount);
AddJsonbParameter(cmd, "metadata", policy.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.escalation_policies WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static EscalationPolicyEntity MapPolicy(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
Enabled = reader.GetBoolean(4),
Steps = reader.GetString(5),
RepeatCount = reader.GetInt32(6),
Metadata = reader.GetString(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
}
public sealed class EscalationStateRepository : RepositoryBase<NotifyDataSource>, IEscalationStateRepository
{
public EscalationStateRepository(NotifyDataSource dataSource, ILogger<EscalationStateRepository> logger)
: base(dataSource, logger) { }
public async Task<EscalationStateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status,
started_at, next_escalation_at, acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
FROM notify.escalation_states WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapState, cancellationToken).ConfigureAwait(false);
}
public async Task<EscalationStateEntity?> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status,
started_at, next_escalation_at, acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
FROM notify.escalation_states WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id AND status = 'active'
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapState, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<EscalationStateEntity>> GetActiveAsync(int limit = 100, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status,
started_at, next_escalation_at, acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
FROM notify.escalation_states WHERE status = 'active' AND next_escalation_at <= NOW()
ORDER BY next_escalation_at LIMIT @limit
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "limit", limit);
var results = new List<EscalationStateEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(MapState(reader));
return results;
}
public async Task<EscalationStateEntity> CreateAsync(EscalationStateEntity state, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.escalation_states (id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status, next_escalation_at, metadata)
VALUES (@id, @tenant_id, @policy_id, @incident_id, @correlation_id, @current_step, @repeat_iteration, @status, @next_escalation_at, @metadata::jsonb)
RETURNING *
""";
var id = state.Id == Guid.Empty ? Guid.NewGuid() : state.Id;
await using var connection = await DataSource.OpenConnectionAsync(state.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", state.TenantId);
AddParameter(command, "policy_id", state.PolicyId);
AddParameter(command, "incident_id", state.IncidentId);
AddParameter(command, "correlation_id", state.CorrelationId);
AddParameter(command, "current_step", state.CurrentStep);
AddParameter(command, "repeat_iteration", state.RepeatIteration);
AddParameter(command, "status", state.Status);
AddParameter(command, "next_escalation_at", state.NextEscalationAt);
AddJsonbParameter(command, "metadata", state.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapState(reader);
}
public async Task<bool> EscalateAsync(string tenantId, Guid id, int newStep, DateTimeOffset? nextEscalationAt, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_states SET current_step = @new_step, next_escalation_at = @next_escalation_at
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "new_step", newStep);
AddParameter(cmd, "next_escalation_at", nextEscalationAt);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> AcknowledgeAsync(string tenantId, Guid id, string acknowledgedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_states SET status = 'acknowledged', acknowledged_at = NOW(), acknowledged_by = @acknowledged_by
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "acknowledged_by", acknowledgedBy);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> ResolveAsync(string tenantId, Guid id, string resolvedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_states SET status = 'resolved', resolved_at = NOW(), resolved_by = @resolved_by
WHERE tenant_id = @tenant_id AND id = @id AND status IN ('active', 'acknowledged')
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "resolved_by", resolvedBy);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static EscalationStateEntity MapState(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
PolicyId = reader.GetGuid(2),
IncidentId = GetNullableGuid(reader, 3),
CorrelationId = reader.GetString(4),
CurrentStep = reader.GetInt32(5),
RepeatIteration = reader.GetInt32(6),
Status = reader.GetString(7),
StartedAt = reader.GetFieldValue<DateTimeOffset>(8),
NextEscalationAt = GetNullableDateTimeOffset(reader, 9),
AcknowledgedAt = GetNullableDateTimeOffset(reader, 10),
AcknowledgedBy = GetNullableString(reader, 11),
ResolvedAt = GetNullableDateTimeOffset(reader, 12),
ResolvedBy = GetNullableString(reader, 13),
Metadata = reader.GetString(14)
};
}

View File

@@ -0,0 +1,15 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IDigestRepository
{
Task<DigestEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<DigestEntity?> GetByKeyAsync(string tenantId, Guid channelId, string recipient, string digestKey, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DigestEntity>> GetReadyToSendAsync(int limit = 100, CancellationToken cancellationToken = default);
Task<DigestEntity> UpsertAsync(DigestEntity digest, CancellationToken cancellationToken = default);
Task<bool> AddEventAsync(string tenantId, Guid id, string eventJson, CancellationToken cancellationToken = default);
Task<bool> MarkSendingAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<bool> MarkSentAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,24 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IEscalationPolicyRepository
{
Task<EscalationPolicyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<EscalationPolicyEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<EscalationPolicyEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<EscalationPolicyEntity> CreateAsync(EscalationPolicyEntity policy, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(EscalationPolicyEntity policy, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}
public interface IEscalationStateRepository
{
Task<EscalationStateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<EscalationStateEntity?> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<EscalationStateEntity>> GetActiveAsync(int limit = 100, CancellationToken cancellationToken = default);
Task<EscalationStateEntity> CreateAsync(EscalationStateEntity state, CancellationToken cancellationToken = default);
Task<bool> EscalateAsync(string tenantId, Guid id, int newStep, DateTimeOffset? nextEscalationAt, CancellationToken cancellationToken = default);
Task<bool> AcknowledgeAsync(string tenantId, Guid id, string acknowledgedBy, CancellationToken cancellationToken = default);
Task<bool> ResolveAsync(string tenantId, Guid id, string resolvedBy, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IInboxRepository
{
Task<InboxEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<InboxEntity>> GetForUserAsync(string tenantId, Guid userId, bool unreadOnly = false, int limit = 50, int offset = 0, CancellationToken cancellationToken = default);
Task<int> GetUnreadCountAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<InboxEntity> CreateAsync(InboxEntity inbox, CancellationToken cancellationToken = default);
Task<bool> MarkReadAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<int> MarkAllReadAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<bool> ArchiveAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IIncidentRepository
{
Task<IncidentEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<IncidentEntity?> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<IncidentEntity>> ListAsync(string tenantId, string? status = null, string? severity = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<IncidentEntity> CreateAsync(IncidentEntity incident, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(IncidentEntity incident, CancellationToken cancellationToken = default);
Task<bool> AcknowledgeAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<bool> ResolveAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<bool> CloseAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<bool> AssignAsync(string tenantId, Guid id, Guid assignedTo, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IMaintenanceWindowRepository
{
Task<MaintenanceWindowEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MaintenanceWindowEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MaintenanceWindowEntity>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default);
Task<MaintenanceWindowEntity> CreateAsync(MaintenanceWindowEntity window, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(MaintenanceWindowEntity window, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<int> DeleteExpiredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,12 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface INotifyAuditRepository
{
Task<long> CreateAsync(NotifyAuditEntity audit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId = null, int limit = 100, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default);
Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IOnCallScheduleRepository
{
Task<OnCallScheduleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<OnCallScheduleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OnCallScheduleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<OnCallScheduleEntity> CreateAsync(OnCallScheduleEntity schedule, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(OnCallScheduleEntity schedule, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IQuietHoursRepository
{
Task<QuietHoursEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<QuietHoursEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<QuietHoursEntity>> GetForUserAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<QuietHoursEntity> CreateAsync(QuietHoursEntity quietHours, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(QuietHoursEntity quietHours, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IRuleRepository
{
Task<RuleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<RuleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RuleEntity>> ListAsync(string tenantId, bool? enabled = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RuleEntity>> GetMatchingRulesAsync(string tenantId, string eventType, CancellationToken cancellationToken = default);
Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(RuleEntity rule, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface ITemplateRepository
{
Task<TemplateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<TemplateEntity?> GetByNameAsync(string tenantId, string name, ChannelType channelType, string locale = "en", CancellationToken cancellationToken = default);
Task<IReadOnlyList<TemplateEntity>> ListAsync(string tenantId, ChannelType? channelType = null, CancellationToken cancellationToken = default);
Task<TemplateEntity> CreateAsync(TemplateEntity template, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(TemplateEntity template, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,139 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class InboxRepository : RepositoryBase<NotifyDataSource>, IInboxRepository
{
public InboxRepository(NotifyDataSource dataSource, ILogger<InboxRepository> logger)
: base(dataSource, logger) { }
public async Task<InboxEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, title, body, event_type, event_payload, read, archived, action_url, correlation_id, created_at, read_at, archived_at
FROM notify.inbox WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapInbox, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<InboxEntity>> GetForUserAsync(string tenantId, Guid userId, bool unreadOnly = false, int limit = 50, int offset = 0, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, user_id, title, body, event_type, event_payload, read, archived, action_url, correlation_id, created_at, read_at, archived_at
FROM notify.inbox WHERE tenant_id = @tenant_id AND user_id = @user_id AND archived = FALSE
""";
if (unreadOnly) sql += " AND read = FALSE";
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "user_id", userId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapInbox, cancellationToken).ConfigureAwait(false);
}
public async Task<int> GetUnreadCountAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = "SELECT COUNT(*) FROM notify.inbox WHERE tenant_id = @tenant_id AND user_id = @user_id AND read = FALSE AND archived = FALSE";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "user_id", userId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
public async Task<InboxEntity> CreateAsync(InboxEntity inbox, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.inbox (id, tenant_id, user_id, title, body, event_type, event_payload, action_url, correlation_id)
VALUES (@id, @tenant_id, @user_id, @title, @body, @event_type, @event_payload::jsonb, @action_url, @correlation_id)
RETURNING *
""";
var id = inbox.Id == Guid.Empty ? Guid.NewGuid() : inbox.Id;
await using var connection = await DataSource.OpenConnectionAsync(inbox.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", inbox.TenantId);
AddParameter(command, "user_id", inbox.UserId);
AddParameter(command, "title", inbox.Title);
AddParameter(command, "body", inbox.Body);
AddParameter(command, "event_type", inbox.EventType);
AddJsonbParameter(command, "event_payload", inbox.EventPayload);
AddParameter(command, "action_url", inbox.ActionUrl);
AddParameter(command, "correlation_id", inbox.CorrelationId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapInbox(reader);
}
public async Task<bool> MarkReadAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND read = FALSE";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> MarkAllReadAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = @tenant_id AND user_id = @user_id AND read = FALSE";
return await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
cancellationToken).ConfigureAwait(false);
}
public async Task<bool> ArchiveAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.inbox SET archived = TRUE, archived_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.inbox WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.inbox WHERE archived = TRUE AND archived_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static InboxEntity MapInbox(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = reader.GetGuid(2),
Title = reader.GetString(3),
Body = GetNullableString(reader, 4),
EventType = reader.GetString(5),
EventPayload = reader.GetString(6),
Read = reader.GetBoolean(7),
Archived = reader.GetBoolean(8),
ActionUrl = GetNullableString(reader, 9),
CorrelationId = GetNullableString(reader, 10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
ReadAt = GetNullableDateTimeOffset(reader, 12),
ArchivedAt = GetNullableDateTimeOffset(reader, 13)
};
}

View File

@@ -0,0 +1,167 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class IncidentRepository : RepositoryBase<NotifyDataSource>, IIncidentRepository
{
public IncidentRepository(NotifyDataSource dataSource, ILogger<IncidentRepository> logger)
: base(dataSource, logger) { }
public async Task<IncidentEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id,
metadata, created_at, acknowledged_at, resolved_at, closed_at, created_by
FROM notify.incidents WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapIncident, cancellationToken).ConfigureAwait(false);
}
public async Task<IncidentEntity?> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id,
metadata, created_at, acknowledged_at, resolved_at, closed_at, created_by
FROM notify.incidents WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapIncident, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<IncidentEntity>> ListAsync(string tenantId, string? status = null, string? severity = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id,
metadata, created_at, acknowledged_at, resolved_at, closed_at, created_by
FROM notify.incidents WHERE tenant_id = @tenant_id
""";
if (status != null) sql += " AND status = @status";
if (severity != null) sql += " AND severity = @severity";
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (status != null) AddParameter(cmd, "status", status);
if (severity != null) AddParameter(cmd, "severity", severity);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapIncident, cancellationToken).ConfigureAwait(false);
}
public async Task<IncidentEntity> CreateAsync(IncidentEntity incident, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.incidents (id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id, metadata, created_by)
VALUES (@id, @tenant_id, @title, @description, @severity, @status, @source, @correlation_id, @assigned_to, @escalation_policy_id, @metadata::jsonb, @created_by)
RETURNING *
""";
var id = incident.Id == Guid.Empty ? Guid.NewGuid() : incident.Id;
await using var connection = await DataSource.OpenConnectionAsync(incident.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", incident.TenantId);
AddParameter(command, "title", incident.Title);
AddParameter(command, "description", incident.Description);
AddParameter(command, "severity", incident.Severity);
AddParameter(command, "status", incident.Status);
AddParameter(command, "source", incident.Source);
AddParameter(command, "correlation_id", incident.CorrelationId);
AddParameter(command, "assigned_to", incident.AssignedTo);
AddParameter(command, "escalation_policy_id", incident.EscalationPolicyId);
AddJsonbParameter(command, "metadata", incident.Metadata);
AddParameter(command, "created_by", incident.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapIncident(reader);
}
public async Task<bool> UpdateAsync(IncidentEntity incident, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.incidents SET title = @title, description = @description, severity = @severity, status = @status,
source = @source, assigned_to = @assigned_to, escalation_policy_id = @escalation_policy_id, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(incident.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", incident.TenantId);
AddParameter(cmd, "id", incident.Id);
AddParameter(cmd, "title", incident.Title);
AddParameter(cmd, "description", incident.Description);
AddParameter(cmd, "severity", incident.Severity);
AddParameter(cmd, "status", incident.Status);
AddParameter(cmd, "source", incident.Source);
AddParameter(cmd, "assigned_to", incident.AssignedTo);
AddParameter(cmd, "escalation_policy_id", incident.EscalationPolicyId);
AddJsonbParameter(cmd, "metadata", incident.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> AcknowledgeAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET status = 'acknowledged', acknowledged_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> ResolveAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET status = 'resolved', resolved_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND status IN ('open', 'acknowledged')";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> CloseAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET status = 'closed', closed_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> AssignAsync(string tenantId, Guid id, Guid assignedTo, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET assigned_to = @assigned_to WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "assigned_to", assignedTo);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static IncidentEntity MapIncident(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Title = reader.GetString(2),
Description = GetNullableString(reader, 3),
Severity = reader.GetString(4),
Status = reader.GetString(5),
Source = GetNullableString(reader, 6),
CorrelationId = GetNullableString(reader, 7),
AssignedTo = GetNullableGuid(reader, 8),
EscalationPolicyId = GetNullableGuid(reader, 9),
Metadata = reader.GetString(10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
AcknowledgedAt = GetNullableDateTimeOffset(reader, 12),
ResolvedAt = GetNullableDateTimeOffset(reader, 13),
ClosedAt = GetNullableDateTimeOffset(reader, 14),
CreatedBy = GetNullableString(reader, 15)
};
}

View File

@@ -0,0 +1,123 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class MaintenanceWindowRepository : RepositoryBase<NotifyDataSource>, IMaintenanceWindowRepository
{
public MaintenanceWindowRepository(NotifyDataSource dataSource, ILogger<MaintenanceWindowRepository> logger)
: base(dataSource, logger) { }
public async Task<MaintenanceWindowEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_at, created_by
FROM notify.maintenance_windows WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapWindow, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<MaintenanceWindowEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_at, created_by
FROM notify.maintenance_windows WHERE tenant_id = @tenant_id ORDER BY start_at DESC
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapWindow, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<MaintenanceWindowEntity>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_at, created_by
FROM notify.maintenance_windows WHERE tenant_id = @tenant_id AND start_at <= NOW() AND end_at > NOW()
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapWindow, cancellationToken).ConfigureAwait(false);
}
public async Task<MaintenanceWindowEntity> CreateAsync(MaintenanceWindowEntity window, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.maintenance_windows (id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_by)
VALUES (@id, @tenant_id, @name, @description, @start_at, @end_at, @suppress_channels, @suppress_event_types, @created_by)
RETURNING *
""";
var id = window.Id == Guid.Empty ? Guid.NewGuid() : window.Id;
await using var connection = await DataSource.OpenConnectionAsync(window.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", window.TenantId);
AddParameter(command, "name", window.Name);
AddParameter(command, "description", window.Description);
AddParameter(command, "start_at", window.StartAt);
AddParameter(command, "end_at", window.EndAt);
AddParameter(command, "suppress_channels", window.SuppressChannels);
AddTextArrayParameter(command, "suppress_event_types", window.SuppressEventTypes ?? []);
AddParameter(command, "created_by", window.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapWindow(reader);
}
public async Task<bool> UpdateAsync(MaintenanceWindowEntity window, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.maintenance_windows SET name = @name, description = @description, start_at = @start_at, end_at = @end_at,
suppress_channels = @suppress_channels, suppress_event_types = @suppress_event_types
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(window.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", window.TenantId);
AddParameter(cmd, "id", window.Id);
AddParameter(cmd, "name", window.Name);
AddParameter(cmd, "description", window.Description);
AddParameter(cmd, "start_at", window.StartAt);
AddParameter(cmd, "end_at", window.EndAt);
AddParameter(cmd, "suppress_channels", window.SuppressChannels);
AddTextArrayParameter(cmd, "suppress_event_types", window.SuppressEventTypes ?? []);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.maintenance_windows WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> DeleteExpiredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.maintenance_windows WHERE end_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static MaintenanceWindowEntity MapWindow(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
StartAt = reader.GetFieldValue<DateTimeOffset>(4),
EndAt = reader.GetFieldValue<DateTimeOffset>(5),
SuppressChannels = reader.IsDBNull(6) ? null : reader.GetFieldValue<Guid[]>(6),
SuppressEventTypes = reader.IsDBNull(7) ? null : reader.GetFieldValue<string[]>(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
CreatedBy = GetNullableString(reader, 9)
};
}

View File

@@ -0,0 +1,100 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class NotifyAuditRepository : RepositoryBase<NotifyDataSource>, INotifyAuditRepository
{
public NotifyAuditRepository(NotifyDataSource dataSource, ILogger<NotifyAuditRepository> logger)
: base(dataSource, logger) { }
public async Task<long> CreateAsync(NotifyAuditEntity audit, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.audit (tenant_id, user_id, action, resource_type, resource_id, details, correlation_id)
VALUES (@tenant_id, @user_id, @action, @resource_type, @resource_id, @details::jsonb, @correlation_id)
RETURNING id
""";
await using var connection = await DataSource.OpenConnectionAsync(audit.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", audit.TenantId);
AddParameter(command, "user_id", audit.UserId);
AddParameter(command, "action", audit.Action);
AddParameter(command, "resource_type", audit.ResourceType);
AddParameter(command, "resource_id", audit.ResourceId);
AddJsonbParameter(command, "details", audit.Details);
AddParameter(command, "correlation_id", audit.CorrelationId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (long)result!;
}
public async Task<IReadOnlyList<NotifyAuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, details, correlation_id, created_at
FROM notify.audit WHERE tenant_id = @tenant_id
ORDER BY created_at DESC LIMIT @limit OFFSET @offset
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<NotifyAuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId = null, int limit = 100, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, details, correlation_id, created_at
FROM notify.audit WHERE tenant_id = @tenant_id AND resource_type = @resource_type
""";
if (resourceId != null) sql += " AND resource_id = @resource_id";
sql += " ORDER BY created_at DESC LIMIT @limit";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "resource_type", resourceType);
if (resourceId != null) AddParameter(cmd, "resource_id", resourceId);
AddParameter(cmd, "limit", limit);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<NotifyAuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, details, correlation_id, created_at
FROM notify.audit WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
ORDER BY created_at
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.audit WHERE created_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static NotifyAuditEntity MapAudit(NpgsqlDataReader reader) => new()
{
Id = reader.GetInt64(0),
TenantId = reader.GetString(1),
UserId = GetNullableGuid(reader, 2),
Action = reader.GetString(3),
ResourceType = reader.GetString(4),
ResourceId = GetNullableString(reader, 5),
Details = GetNullableString(reader, 6),
CorrelationId = GetNullableString(reader, 7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8)
};
}

View File

@@ -0,0 +1,116 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class OnCallScheduleRepository : RepositoryBase<NotifyDataSource>, IOnCallScheduleRepository
{
public OnCallScheduleRepository(NotifyDataSource dataSource, ILogger<OnCallScheduleRepository> logger)
: base(dataSource, logger) { }
public async Task<OnCallScheduleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata, created_at, updated_at
FROM notify.on_call_schedules WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapSchedule, cancellationToken).ConfigureAwait(false);
}
public async Task<OnCallScheduleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata, created_at, updated_at
FROM notify.on_call_schedules WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapSchedule, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OnCallScheduleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata, created_at, updated_at
FROM notify.on_call_schedules WHERE tenant_id = @tenant_id ORDER BY name
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapSchedule, cancellationToken).ConfigureAwait(false);
}
public async Task<OnCallScheduleEntity> CreateAsync(OnCallScheduleEntity schedule, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.on_call_schedules (id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata)
VALUES (@id, @tenant_id, @name, @description, @timezone, @rotation_type, @participants::jsonb, @overrides::jsonb, @metadata::jsonb)
RETURNING *
""";
var id = schedule.Id == Guid.Empty ? Guid.NewGuid() : schedule.Id;
await using var connection = await DataSource.OpenConnectionAsync(schedule.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", schedule.TenantId);
AddParameter(command, "name", schedule.Name);
AddParameter(command, "description", schedule.Description);
AddParameter(command, "timezone", schedule.Timezone);
AddParameter(command, "rotation_type", schedule.RotationType);
AddJsonbParameter(command, "participants", schedule.Participants);
AddJsonbParameter(command, "overrides", schedule.Overrides);
AddJsonbParameter(command, "metadata", schedule.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapSchedule(reader);
}
public async Task<bool> UpdateAsync(OnCallScheduleEntity schedule, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.on_call_schedules SET name = @name, description = @description, timezone = @timezone,
rotation_type = @rotation_type, participants = @participants::jsonb, overrides = @overrides::jsonb, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(schedule.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", schedule.TenantId);
AddParameter(cmd, "id", schedule.Id);
AddParameter(cmd, "name", schedule.Name);
AddParameter(cmd, "description", schedule.Description);
AddParameter(cmd, "timezone", schedule.Timezone);
AddParameter(cmd, "rotation_type", schedule.RotationType);
AddJsonbParameter(cmd, "participants", schedule.Participants);
AddJsonbParameter(cmd, "overrides", schedule.Overrides);
AddJsonbParameter(cmd, "metadata", schedule.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.on_call_schedules WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static OnCallScheduleEntity MapSchedule(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
Timezone = reader.GetString(4),
RotationType = reader.GetString(5),
Participants = reader.GetString(6),
Overrides = reader.GetString(7),
Metadata = reader.GetString(8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
}

View File

@@ -0,0 +1,116 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class QuietHoursRepository : RepositoryBase<NotifyDataSource>, IQuietHoursRepository
{
public QuietHoursRepository(NotifyDataSource dataSource, ILogger<QuietHoursRepository> logger)
: base(dataSource, logger) { }
public async Task<QuietHoursEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled, created_at, updated_at
FROM notify.quiet_hours WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapQuietHours, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<QuietHoursEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled, created_at, updated_at
FROM notify.quiet_hours WHERE tenant_id = @tenant_id ORDER BY start_time
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapQuietHours, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<QuietHoursEntity>> GetForUserAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled, created_at, updated_at
FROM notify.quiet_hours WHERE tenant_id = @tenant_id AND (user_id IS NULL OR user_id = @user_id) AND enabled = TRUE
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
MapQuietHours, cancellationToken).ConfigureAwait(false);
}
public async Task<QuietHoursEntity> CreateAsync(QuietHoursEntity quietHours, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.quiet_hours (id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled)
VALUES (@id, @tenant_id, @user_id, @channel_id, @start_time, @end_time, @timezone, @days_of_week, @enabled)
RETURNING *
""";
var id = quietHours.Id == Guid.Empty ? Guid.NewGuid() : quietHours.Id;
await using var connection = await DataSource.OpenConnectionAsync(quietHours.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", quietHours.TenantId);
AddParameter(command, "user_id", quietHours.UserId);
AddParameter(command, "channel_id", quietHours.ChannelId);
AddParameter(command, "start_time", quietHours.StartTime);
AddParameter(command, "end_time", quietHours.EndTime);
AddParameter(command, "timezone", quietHours.Timezone);
AddParameter(command, "days_of_week", quietHours.DaysOfWeek);
AddParameter(command, "enabled", quietHours.Enabled);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapQuietHours(reader);
}
public async Task<bool> UpdateAsync(QuietHoursEntity quietHours, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.quiet_hours SET user_id = @user_id, channel_id = @channel_id, start_time = @start_time, end_time = @end_time,
timezone = @timezone, days_of_week = @days_of_week, enabled = @enabled
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(quietHours.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", quietHours.TenantId);
AddParameter(cmd, "id", quietHours.Id);
AddParameter(cmd, "user_id", quietHours.UserId);
AddParameter(cmd, "channel_id", quietHours.ChannelId);
AddParameter(cmd, "start_time", quietHours.StartTime);
AddParameter(cmd, "end_time", quietHours.EndTime);
AddParameter(cmd, "timezone", quietHours.Timezone);
AddParameter(cmd, "days_of_week", quietHours.DaysOfWeek);
AddParameter(cmd, "enabled", quietHours.Enabled);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.quiet_hours WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static QuietHoursEntity MapQuietHours(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = GetNullableGuid(reader, 2),
ChannelId = GetNullableGuid(reader, 3),
StartTime = reader.GetFieldValue<TimeOnly>(4),
EndTime = reader.GetFieldValue<TimeOnly>(5),
Timezone = reader.GetString(6),
DaysOfWeek = reader.IsDBNull(7) ? [0, 1, 2, 3, 4, 5, 6] : reader.GetFieldValue<int[]>(7),
Enabled = reader.GetBoolean(8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
}

View File

@@ -0,0 +1,139 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class RuleRepository : RepositoryBase<NotifyDataSource>, IRuleRepository
{
public RuleRepository(NotifyDataSource dataSource, ILogger<RuleRepository> logger)
: base(dataSource, logger) { }
public async Task<RuleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapRule, cancellationToken).ConfigureAwait(false);
}
public async Task<RuleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapRule, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RuleEntity>> ListAsync(string tenantId, bool? enabled = null, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id
""";
if (enabled.HasValue) sql += " AND enabled = @enabled";
sql += " ORDER BY priority DESC, name";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (enabled.HasValue) AddParameter(cmd, "enabled", enabled.Value);
}, MapRule, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RuleEntity>> GetMatchingRulesAsync(string tenantId, string eventType, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id AND enabled = TRUE AND @event_type = ANY(event_types)
ORDER BY priority DESC
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "event_type", eventType); },
MapRule, cancellationToken).ConfigureAwait(false);
}
public async Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.rules (id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata)
VALUES (@id, @tenant_id, @name, @description, @enabled, @priority, @event_types, @filter::jsonb, @channel_ids, @template_id, @metadata::jsonb)
RETURNING *
""";
var id = rule.Id == Guid.Empty ? Guid.NewGuid() : rule.Id;
await using var connection = await DataSource.OpenConnectionAsync(rule.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", rule.TenantId);
AddParameter(command, "name", rule.Name);
AddParameter(command, "description", rule.Description);
AddParameter(command, "enabled", rule.Enabled);
AddParameter(command, "priority", rule.Priority);
AddTextArrayParameter(command, "event_types", rule.EventTypes);
AddJsonbParameter(command, "filter", rule.Filter);
AddParameter(command, "channel_ids", rule.ChannelIds);
AddParameter(command, "template_id", rule.TemplateId);
AddJsonbParameter(command, "metadata", rule.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapRule(reader);
}
public async Task<bool> UpdateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.rules SET name = @name, description = @description, enabled = @enabled, priority = @priority,
event_types = @event_types, filter = @filter::jsonb, channel_ids = @channel_ids, template_id = @template_id, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(rule.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", rule.TenantId);
AddParameter(cmd, "id", rule.Id);
AddParameter(cmd, "name", rule.Name);
AddParameter(cmd, "description", rule.Description);
AddParameter(cmd, "enabled", rule.Enabled);
AddParameter(cmd, "priority", rule.Priority);
AddTextArrayParameter(cmd, "event_types", rule.EventTypes);
AddJsonbParameter(cmd, "filter", rule.Filter);
AddParameter(cmd, "channel_ids", rule.ChannelIds);
AddParameter(cmd, "template_id", rule.TemplateId);
AddJsonbParameter(cmd, "metadata", rule.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.rules WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static RuleEntity MapRule(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
Enabled = reader.GetBoolean(4),
Priority = reader.GetInt32(5),
EventTypes = reader.IsDBNull(6) ? [] : reader.GetFieldValue<string[]>(6),
Filter = reader.GetString(7),
ChannelIds = reader.IsDBNull(8) ? [] : reader.GetFieldValue<Guid[]>(8),
TemplateId = GetNullableGuid(reader, 9),
Metadata = reader.GetString(10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(12)
};
}

View File

@@ -0,0 +1,136 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class TemplateRepository : RepositoryBase<NotifyDataSource>, ITemplateRepository
{
public TemplateRepository(NotifyDataSource dataSource, ILogger<TemplateRepository> logger)
: base(dataSource, logger) { }
public async Task<TemplateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
FROM notify.templates WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapTemplate, cancellationToken).ConfigureAwait(false);
}
public async Task<TemplateEntity?> GetByNameAsync(string tenantId, string name, ChannelType channelType, string locale = "en", CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
FROM notify.templates WHERE tenant_id = @tenant_id AND name = @name AND channel_type = @channel_type::notify.channel_type AND locale = @locale
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
AddParameter(cmd, "channel_type", ChannelTypeToString(channelType));
AddParameter(cmd, "locale", locale);
}, MapTemplate, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<TemplateEntity>> ListAsync(string tenantId, ChannelType? channelType = null, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
FROM notify.templates WHERE tenant_id = @tenant_id
""";
if (channelType.HasValue) sql += " AND channel_type = @channel_type::notify.channel_type";
sql += " ORDER BY name, locale";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (channelType.HasValue) AddParameter(cmd, "channel_type", ChannelTypeToString(channelType.Value));
}, MapTemplate, cancellationToken).ConfigureAwait(false);
}
public async Task<TemplateEntity> CreateAsync(TemplateEntity template, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.templates (id, tenant_id, name, channel_type, subject_template, body_template, locale, metadata)
VALUES (@id, @tenant_id, @name, @channel_type::notify.channel_type, @subject_template, @body_template, @locale, @metadata::jsonb)
RETURNING id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
""";
var id = template.Id == Guid.Empty ? Guid.NewGuid() : template.Id;
await using var connection = await DataSource.OpenConnectionAsync(template.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", template.TenantId);
AddParameter(command, "name", template.Name);
AddParameter(command, "channel_type", ChannelTypeToString(template.ChannelType));
AddParameter(command, "subject_template", template.SubjectTemplate);
AddParameter(command, "body_template", template.BodyTemplate);
AddParameter(command, "locale", template.Locale);
AddJsonbParameter(command, "metadata", template.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapTemplate(reader);
}
public async Task<bool> UpdateAsync(TemplateEntity template, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.templates SET name = @name, channel_type = @channel_type::notify.channel_type,
subject_template = @subject_template, body_template = @body_template, locale = @locale, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(template.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", template.TenantId);
AddParameter(cmd, "id", template.Id);
AddParameter(cmd, "name", template.Name);
AddParameter(cmd, "channel_type", ChannelTypeToString(template.ChannelType));
AddParameter(cmd, "subject_template", template.SubjectTemplate);
AddParameter(cmd, "body_template", template.BodyTemplate);
AddParameter(cmd, "locale", template.Locale);
AddJsonbParameter(cmd, "metadata", template.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.templates WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static TemplateEntity MapTemplate(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
ChannelType = ParseChannelType(reader.GetString(3)),
SubjectTemplate = GetNullableString(reader, 4),
BodyTemplate = reader.GetString(5),
Locale = reader.GetString(6),
Metadata = reader.GetString(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
private static string ChannelTypeToString(ChannelType t) => t switch
{
ChannelType.Email => "email", ChannelType.Slack => "slack", ChannelType.Teams => "teams",
ChannelType.Webhook => "webhook", ChannelType.PagerDuty => "pagerduty", ChannelType.OpsGenie => "opsgenie",
_ => throw new ArgumentException($"Unknown: {t}")
};
private static ChannelType ParseChannelType(string s) => s switch
{
"email" => ChannelType.Email, "slack" => ChannelType.Slack, "teams" => ChannelType.Teams,
"webhook" => ChannelType.Webhook, "pagerduty" => ChannelType.PagerDuty, "opsgenie" => ChannelType.OpsGenie,
_ => throw new ArgumentException($"Unknown: {s}")
};
}