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
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:
@@ -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; }
|
||||
}
|
||||
@@ -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; } = "{}";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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}")
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public interface IReceiptBuilder
|
||||
/// </summary>
|
||||
public sealed class ReceiptBuilder : IReceiptBuilder
|
||||
{
|
||||
internal static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
public static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
@@ -164,7 +165,8 @@ returning {Columns};";
|
||||
Hash = reader.GetString(idx.PolicyHash),
|
||||
ActivatedAt = null
|
||||
},
|
||||
BaseMetrics = Deserialize<CvssBaseMetrics>(reader, idx.BaseMetrics),
|
||||
BaseMetrics = Deserialize<CvssBaseMetrics>(reader, idx.BaseMetrics)
|
||||
?? throw new InvalidOperationException("cvss_receipts.base_metrics missing"),
|
||||
ThreatMetrics = Deserialize<CvssThreatMetrics>(reader, idx.ThreatMetrics),
|
||||
EnvironmentalMetrics = Deserialize<CvssEnvironmentalMetrics>(reader, idx.EnvironmentalMetrics),
|
||||
SupplementalMetrics = Deserialize<CvssSupplementalMetrics>(reader, idx.SupplementalMetrics),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a job history entity in the scheduler schema.
|
||||
/// </summary>
|
||||
public sealed class JobHistoryEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique history entry identifier.
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original job ID.
|
||||
/// </summary>
|
||||
public required Guid JobId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant this job belonged to.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional project identifier.
|
||||
/// </summary>
|
||||
public string? ProjectId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of job that was executed.
|
||||
/// </summary>
|
||||
public required string JobType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final job status.
|
||||
/// </summary>
|
||||
public JobStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempt number when archived.
|
||||
/// </summary>
|
||||
public int Attempt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of payload.
|
||||
/// </summary>
|
||||
public required string PayloadDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Job result as JSON.
|
||||
/// </summary>
|
||||
public string? Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for failure/cancellation.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Worker that executed the job.
|
||||
/// </summary>
|
||||
public string? WorkerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration in milliseconds.
|
||||
/// </summary>
|
||||
public long? DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the job was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the job completed.
|
||||
/// </summary>
|
||||
public DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the job was archived to history.
|
||||
/// </summary>
|
||||
public DateTimeOffset ArchivedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a distributed lock entity in the scheduler schema.
|
||||
/// </summary>
|
||||
public sealed class LockEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Lock key (primary key).
|
||||
/// </summary>
|
||||
public required string LockKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant this lock belongs to.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the holder that acquired the lock.
|
||||
/// </summary>
|
||||
public required string HolderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the lock was acquired.
|
||||
/// </summary>
|
||||
public DateTimeOffset AcquiredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the lock expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Lock metadata as JSON.
|
||||
/// </summary>
|
||||
public string Metadata { get; init; } = "{}";
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a metrics entity in the scheduler schema.
|
||||
/// </summary>
|
||||
public sealed class MetricsEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique metrics entry identifier.
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant this metric belongs to.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Job type for this metric.
|
||||
/// </summary>
|
||||
public required string JobType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Period start time.
|
||||
/// </summary>
|
||||
public DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Period end time.
|
||||
/// </summary>
|
||||
public DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of jobs created in this period.
|
||||
/// </summary>
|
||||
public long JobsCreated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of jobs completed in this period.
|
||||
/// </summary>
|
||||
public long JobsCompleted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of jobs failed in this period.
|
||||
/// </summary>
|
||||
public long JobsFailed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of jobs timed out in this period.
|
||||
/// </summary>
|
||||
public long JobsTimedOut { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Average duration in milliseconds.
|
||||
/// </summary>
|
||||
public long? AvgDurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 50th percentile duration.
|
||||
/// </summary>
|
||||
public long? P50DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 95th percentile duration.
|
||||
/// </summary>
|
||||
public long? P95DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 99th percentile duration.
|
||||
/// </summary>
|
||||
public long? P99DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this metric was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Worker status values.
|
||||
/// </summary>
|
||||
public static class WorkerStatus
|
||||
{
|
||||
public const string Active = "active";
|
||||
public const string Draining = "draining";
|
||||
public const string Stopped = "stopped";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a worker entity in the scheduler schema.
|
||||
/// </summary>
|
||||
public sealed class WorkerEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique worker identifier.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant this worker is dedicated to.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hostname of the worker.
|
||||
/// </summary>
|
||||
public required string Hostname { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Process ID of the worker.
|
||||
/// </summary>
|
||||
public int? ProcessId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Job types this worker can process.
|
||||
/// </summary>
|
||||
public string[] JobTypes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maximum concurrent jobs this worker can handle.
|
||||
/// </summary>
|
||||
public int MaxConcurrentJobs { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Current number of jobs being processed.
|
||||
/// </summary>
|
||||
public int CurrentJobs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Worker status.
|
||||
/// </summary>
|
||||
public string Status { get; init; } = WorkerStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// Last heartbeat timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastHeartbeatAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the worker was registered.
|
||||
/// </summary>
|
||||
public DateTimeOffset RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Worker metadata as JSON.
|
||||
/// </summary>
|
||||
public string Metadata { get; init; } = "{}";
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for distributed lock operations.
|
||||
/// </summary>
|
||||
public sealed class DistributedLockRepository : RepositoryBase<SchedulerDataSource>, IDistributedLockRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new distributed lock repository.
|
||||
/// </summary>
|
||||
public DistributedLockRepository(SchedulerDataSource dataSource, ILogger<DistributedLockRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryAcquireAsync(string tenantId, string lockKey, string holderId, TimeSpan duration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scheduler.locks (lock_key, tenant_id, holder_id, expires_at)
|
||||
VALUES (@lock_key, @tenant_id, @holder_id, NOW() + @duration)
|
||||
ON CONFLICT (lock_key) DO UPDATE SET
|
||||
holder_id = EXCLUDED.holder_id,
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
acquired_at = NOW(),
|
||||
expires_at = NOW() + EXCLUDED.expires_at - EXCLUDED.acquired_at
|
||||
WHERE scheduler.locks.expires_at < NOW()
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "lock_key", lockKey);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "holder_id", holderId);
|
||||
AddParameter(command, "duration", duration);
|
||||
|
||||
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LockEntity?> GetAsync(string lockKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT lock_key, tenant_id, holder_id, acquired_at, expires_at, metadata
|
||||
FROM scheduler.locks
|
||||
WHERE lock_key = @lock_key AND expires_at > NOW()
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "lock_key", lockKey);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapLock(reader) : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ExtendAsync(string lockKey, string holderId, TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE scheduler.locks
|
||||
SET expires_at = expires_at + @extension
|
||||
WHERE lock_key = @lock_key AND holder_id = @holder_id AND expires_at > NOW()
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "lock_key", lockKey);
|
||||
AddParameter(command, "holder_id", holderId);
|
||||
AddParameter(command, "extension", extension);
|
||||
|
||||
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ReleaseAsync(string lockKey, string holderId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM scheduler.locks
|
||||
WHERE lock_key = @lock_key AND holder_id = @holder_id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "lock_key", lockKey);
|
||||
AddParameter(command, "holder_id", holderId);
|
||||
|
||||
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CleanupExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM scheduler.locks WHERE expires_at < NOW()";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<LockEntity>> ListByTenantAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT lock_key, tenant_id, holder_id, acquired_at, expires_at, metadata
|
||||
FROM scheduler.locks
|
||||
WHERE tenant_id = @tenant_id AND expires_at > NOW()
|
||||
ORDER BY acquired_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
|
||||
var results = new List<LockEntity>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapLock(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static LockEntity MapLock(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
LockKey = reader.GetString(reader.GetOrdinal("lock_key")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
HolderId = reader.GetString(reader.GetOrdinal("holder_id")),
|
||||
AcquiredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("acquired_at")),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("expires_at")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for distributed lock operations.
|
||||
/// </summary>
|
||||
public interface IDistributedLockRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to acquire a lock.
|
||||
/// </summary>
|
||||
Task<bool> TryAcquireAsync(string tenantId, string lockKey, string holderId, TimeSpan duration, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a lock by key.
|
||||
/// </summary>
|
||||
Task<LockEntity?> GetAsync(string lockKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extends a lock.
|
||||
/// </summary>
|
||||
Task<bool> ExtendAsync(string lockKey, string holderId, TimeSpan extension, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Releases a lock.
|
||||
/// </summary>
|
||||
Task<bool> ReleaseAsync(string lockKey, string holderId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up expired locks.
|
||||
/// </summary>
|
||||
Task<int> CleanupExpiredAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all locks for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<LockEntity>> ListByTenantAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for job history operations.
|
||||
/// </summary>
|
||||
public interface IJobHistoryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Archives a completed job.
|
||||
/// </summary>
|
||||
Task<JobHistoryEntity> ArchiveAsync(JobHistoryEntity history, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets history for a specific job.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<JobHistoryEntity>> GetByJobIdAsync(Guid jobId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists job history for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<JobHistoryEntity>> ListAsync(
|
||||
string tenantId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists job history by type.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<JobHistoryEntity>> ListByJobTypeAsync(
|
||||
string tenantId,
|
||||
string jobType,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists job history by status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<JobHistoryEntity>> ListByStatusAsync(
|
||||
string tenantId,
|
||||
JobStatus status,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists job history in a time range.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<JobHistoryEntity>> ListByTimeRangeAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit = 1000,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes old history entries.
|
||||
/// </summary>
|
||||
Task<int> DeleteOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for metrics operations.
|
||||
/// </summary>
|
||||
public interface IMetricsRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Records or updates metrics for a period.
|
||||
/// </summary>
|
||||
Task<MetricsEntity> UpsertAsync(MetricsEntity metrics, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets metrics for a tenant and job type.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MetricsEntity>> GetAsync(
|
||||
string tenantId,
|
||||
string jobType,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated metrics for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MetricsEntity>> GetByTenantAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets latest metrics for all job types.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MetricsEntity>> GetLatestAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes old metrics.
|
||||
/// </summary>
|
||||
Task<int> DeleteOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for trigger operations.
|
||||
/// </summary>
|
||||
public interface ITriggerRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a trigger by ID.
|
||||
/// </summary>
|
||||
Task<TriggerEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a trigger by name.
|
||||
/// </summary>
|
||||
Task<TriggerEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all triggers for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TriggerEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets triggers that are due to fire.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TriggerEntity>> GetDueTriggersAsync(int limit = 100, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new trigger.
|
||||
/// </summary>
|
||||
Task<TriggerEntity> CreateAsync(TriggerEntity trigger, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a trigger.
|
||||
/// </summary>
|
||||
Task<bool> UpdateAsync(TriggerEntity trigger, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records a trigger fire event.
|
||||
/// </summary>
|
||||
Task<bool> RecordFireAsync(string tenantId, Guid triggerId, Guid jobId, DateTimeOffset? nextFireAt, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records a trigger misfire.
|
||||
/// </summary>
|
||||
Task<bool> RecordMisfireAsync(string tenantId, Guid triggerId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enables or disables a trigger.
|
||||
/// </summary>
|
||||
Task<bool> SetEnabledAsync(string tenantId, Guid id, bool enabled, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a trigger.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for worker operations.
|
||||
/// </summary>
|
||||
public interface IWorkerRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a worker by ID.
|
||||
/// </summary>
|
||||
Task<WorkerEntity?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all workers.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WorkerEntity>> ListAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists workers by status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WorkerEntity>> ListByStatusAsync(string status, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets workers for a specific tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WorkerEntity>> GetByTenantIdAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new worker or updates existing.
|
||||
/// </summary>
|
||||
Task<WorkerEntity> UpsertAsync(WorkerEntity worker, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates worker heartbeat.
|
||||
/// </summary>
|
||||
Task<bool> HeartbeatAsync(string id, int currentJobs, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates worker status.
|
||||
/// </summary>
|
||||
Task<bool> SetStatusAsync(string id, string status, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a worker.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets stale workers (no heartbeat in duration).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WorkerEntity>> GetStaleWorkersAsync(TimeSpan staleDuration, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for job history operations.
|
||||
/// </summary>
|
||||
public sealed class JobHistoryRepository : RepositoryBase<SchedulerDataSource>, IJobHistoryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new job history repository.
|
||||
/// </summary>
|
||||
public JobHistoryRepository(SchedulerDataSource dataSource, ILogger<JobHistoryRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<JobHistoryEntity> ArchiveAsync(JobHistoryEntity history, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scheduler.job_history (
|
||||
job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
|
||||
result, reason, worker_id, duration_ms, created_at, completed_at
|
||||
)
|
||||
VALUES (
|
||||
@job_id, @tenant_id, @project_id, @job_type, @status::scheduler.job_status, @attempt, @payload_digest,
|
||||
@result::jsonb, @reason, @worker_id, @duration_ms, @created_at, @completed_at
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(history.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "job_id", history.JobId);
|
||||
AddParameter(command, "tenant_id", history.TenantId);
|
||||
AddParameter(command, "project_id", history.ProjectId);
|
||||
AddParameter(command, "job_type", history.JobType);
|
||||
AddParameter(command, "status", history.Status.ToString().ToLowerInvariant());
|
||||
AddParameter(command, "attempt", history.Attempt);
|
||||
AddParameter(command, "payload_digest", history.PayloadDigest);
|
||||
AddJsonbParameter(command, "result", history.Result);
|
||||
AddParameter(command, "reason", history.Reason);
|
||||
AddParameter(command, "worker_id", history.WorkerId);
|
||||
AddParameter(command, "duration_ms", history.DurationMs);
|
||||
AddParameter(command, "created_at", history.CreatedAt);
|
||||
AddParameter(command, "completed_at", history.CompletedAt);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return MapJobHistory(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<JobHistoryEntity>> GetByJobIdAsync(Guid jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
|
||||
result, reason, worker_id, duration_ms, created_at, completed_at, archived_at
|
||||
FROM scheduler.job_history
|
||||
WHERE job_id = @job_id
|
||||
ORDER BY archived_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "job_id", jobId);
|
||||
|
||||
var results = new List<JobHistoryEntity>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapJobHistory(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<JobHistoryEntity>> ListAsync(
|
||||
string tenantId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
|
||||
result, reason, worker_id, duration_ms, created_at, completed_at, archived_at
|
||||
FROM scheduler.job_history
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY completed_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);
|
||||
},
|
||||
MapJobHistory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<JobHistoryEntity>> ListByJobTypeAsync(
|
||||
string tenantId,
|
||||
string jobType,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
|
||||
result, reason, worker_id, duration_ms, created_at, completed_at, archived_at
|
||||
FROM scheduler.job_history
|
||||
WHERE tenant_id = @tenant_id AND job_type = @job_type
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "job_type", jobType);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapJobHistory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<JobHistoryEntity>> ListByStatusAsync(
|
||||
string tenantId,
|
||||
JobStatus status,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
|
||||
result, reason, worker_id, duration_ms, created_at, completed_at, archived_at
|
||||
FROM scheduler.job_history
|
||||
WHERE tenant_id = @tenant_id AND status = @status::scheduler.job_status
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "status", status.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapJobHistory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<JobHistoryEntity>> ListByTimeRangeAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit = 1000,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
|
||||
result, reason, worker_id, duration_ms, created_at, completed_at, archived_at
|
||||
FROM scheduler.job_history
|
||||
WHERE tenant_id = @tenant_id AND completed_at >= @from AND completed_at < @to
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "from", from);
|
||||
AddParameter(cmd, "to", to);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapJobHistory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM scheduler.job_history WHERE 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 JobHistoryEntity MapJobHistory(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetInt64(reader.GetOrdinal("id")),
|
||||
JobId = reader.GetGuid(reader.GetOrdinal("job_id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
ProjectId = GetNullableString(reader, reader.GetOrdinal("project_id")),
|
||||
JobType = reader.GetString(reader.GetOrdinal("job_type")),
|
||||
Status = ParseJobStatus(reader.GetString(reader.GetOrdinal("status"))),
|
||||
Attempt = reader.GetInt32(reader.GetOrdinal("attempt")),
|
||||
PayloadDigest = reader.GetString(reader.GetOrdinal("payload_digest")),
|
||||
Result = GetNullableString(reader, reader.GetOrdinal("result")),
|
||||
Reason = GetNullableString(reader, reader.GetOrdinal("reason")),
|
||||
WorkerId = GetNullableString(reader, reader.GetOrdinal("worker_id")),
|
||||
DurationMs = reader.IsDBNull(reader.GetOrdinal("duration_ms")) ? null : reader.GetInt64(reader.GetOrdinal("duration_ms")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
CompletedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("completed_at")),
|
||||
ArchivedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("archived_at"))
|
||||
};
|
||||
|
||||
private static JobStatus ParseJobStatus(string status) => status switch
|
||||
{
|
||||
"pending" => JobStatus.Pending,
|
||||
"scheduled" => JobStatus.Scheduled,
|
||||
"leased" => JobStatus.Leased,
|
||||
"running" => JobStatus.Running,
|
||||
"succeeded" => JobStatus.Succeeded,
|
||||
"failed" => JobStatus.Failed,
|
||||
"canceled" => JobStatus.Canceled,
|
||||
"timed_out" => JobStatus.TimedOut,
|
||||
_ => throw new ArgumentException($"Unknown job status: {status}", nameof(status))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for metrics operations.
|
||||
/// </summary>
|
||||
public sealed class MetricsRepository : RepositoryBase<SchedulerDataSource>, IMetricsRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new metrics repository.
|
||||
/// </summary>
|
||||
public MetricsRepository(SchedulerDataSource dataSource, ILogger<MetricsRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MetricsEntity> UpsertAsync(MetricsEntity metrics, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scheduler.metrics (
|
||||
tenant_id, job_type, period_start, period_end, jobs_created, jobs_completed,
|
||||
jobs_failed, jobs_timed_out, avg_duration_ms, p50_duration_ms, p95_duration_ms, p99_duration_ms
|
||||
)
|
||||
VALUES (
|
||||
@tenant_id, @job_type, @period_start, @period_end, @jobs_created, @jobs_completed,
|
||||
@jobs_failed, @jobs_timed_out, @avg_duration_ms, @p50_duration_ms, @p95_duration_ms, @p99_duration_ms
|
||||
)
|
||||
ON CONFLICT (tenant_id, job_type, period_start) DO UPDATE SET
|
||||
period_end = EXCLUDED.period_end,
|
||||
jobs_created = EXCLUDED.jobs_created,
|
||||
jobs_completed = EXCLUDED.jobs_completed,
|
||||
jobs_failed = EXCLUDED.jobs_failed,
|
||||
jobs_timed_out = EXCLUDED.jobs_timed_out,
|
||||
avg_duration_ms = EXCLUDED.avg_duration_ms,
|
||||
p50_duration_ms = EXCLUDED.p50_duration_ms,
|
||||
p95_duration_ms = EXCLUDED.p95_duration_ms,
|
||||
p99_duration_ms = EXCLUDED.p99_duration_ms
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(metrics.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", metrics.TenantId);
|
||||
AddParameter(command, "job_type", metrics.JobType);
|
||||
AddParameter(command, "period_start", metrics.PeriodStart);
|
||||
AddParameter(command, "period_end", metrics.PeriodEnd);
|
||||
AddParameter(command, "jobs_created", metrics.JobsCreated);
|
||||
AddParameter(command, "jobs_completed", metrics.JobsCompleted);
|
||||
AddParameter(command, "jobs_failed", metrics.JobsFailed);
|
||||
AddParameter(command, "jobs_timed_out", metrics.JobsTimedOut);
|
||||
AddParameter(command, "avg_duration_ms", metrics.AvgDurationMs);
|
||||
AddParameter(command, "p50_duration_ms", metrics.P50DurationMs);
|
||||
AddParameter(command, "p95_duration_ms", metrics.P95DurationMs);
|
||||
AddParameter(command, "p99_duration_ms", metrics.P99DurationMs);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return MapMetrics(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MetricsEntity>> GetAsync(
|
||||
string tenantId,
|
||||
string jobType,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, job_type, period_start, period_end, jobs_created, jobs_completed,
|
||||
jobs_failed, jobs_timed_out, avg_duration_ms, p50_duration_ms, p95_duration_ms, p99_duration_ms, created_at
|
||||
FROM scheduler.metrics
|
||||
WHERE tenant_id = @tenant_id AND job_type = @job_type
|
||||
AND period_start >= @from AND period_start < @to
|
||||
ORDER BY period_start
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "job_type", jobType);
|
||||
AddParameter(cmd, "from", from);
|
||||
AddParameter(cmd, "to", to);
|
||||
},
|
||||
MapMetrics,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MetricsEntity>> GetByTenantAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, job_type, period_start, period_end, jobs_created, jobs_completed,
|
||||
jobs_failed, jobs_timed_out, avg_duration_ms, p50_duration_ms, p95_duration_ms, p99_duration_ms, created_at
|
||||
FROM scheduler.metrics
|
||||
WHERE tenant_id = @tenant_id AND period_start >= @from AND period_start < @to
|
||||
ORDER BY period_start, job_type
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "from", from);
|
||||
AddParameter(cmd, "to", to);
|
||||
},
|
||||
MapMetrics,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MetricsEntity>> GetLatestAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT DISTINCT ON (job_type) id, tenant_id, job_type, period_start, period_end,
|
||||
jobs_created, jobs_completed, jobs_failed, jobs_timed_out,
|
||||
avg_duration_ms, p50_duration_ms, p95_duration_ms, p99_duration_ms, created_at
|
||||
FROM scheduler.metrics
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY job_type, period_start DESC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapMetrics,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM scheduler.metrics WHERE period_end < @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 MetricsEntity MapMetrics(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetInt64(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
JobType = reader.GetString(reader.GetOrdinal("job_type")),
|
||||
PeriodStart = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("period_start")),
|
||||
PeriodEnd = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("period_end")),
|
||||
JobsCreated = reader.GetInt64(reader.GetOrdinal("jobs_created")),
|
||||
JobsCompleted = reader.GetInt64(reader.GetOrdinal("jobs_completed")),
|
||||
JobsFailed = reader.GetInt64(reader.GetOrdinal("jobs_failed")),
|
||||
JobsTimedOut = reader.GetInt64(reader.GetOrdinal("jobs_timed_out")),
|
||||
AvgDurationMs = reader.IsDBNull(reader.GetOrdinal("avg_duration_ms")) ? null : reader.GetInt64(reader.GetOrdinal("avg_duration_ms")),
|
||||
P50DurationMs = reader.IsDBNull(reader.GetOrdinal("p50_duration_ms")) ? null : reader.GetInt64(reader.GetOrdinal("p50_duration_ms")),
|
||||
P95DurationMs = reader.IsDBNull(reader.GetOrdinal("p95_duration_ms")) ? null : reader.GetInt64(reader.GetOrdinal("p95_duration_ms")),
|
||||
P99DurationMs = reader.IsDBNull(reader.GetOrdinal("p99_duration_ms")) ? null : reader.GetInt64(reader.GetOrdinal("p99_duration_ms")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for trigger operations.
|
||||
/// </summary>
|
||||
public sealed class TriggerRepository : RepositoryBase<SchedulerDataSource>, ITriggerRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new trigger repository.
|
||||
/// </summary>
|
||||
public TriggerRepository(SchedulerDataSource dataSource, ILogger<TriggerRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TriggerEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, description, job_type, job_payload, cron_expression, timezone,
|
||||
enabled, next_fire_at, last_fire_at, last_job_id, fire_count, misfire_count,
|
||||
metadata, created_at, updated_at, created_by
|
||||
FROM scheduler.triggers
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapTrigger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TriggerEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, description, job_type, job_payload, cron_expression, timezone,
|
||||
enabled, next_fire_at, last_fire_at, last_job_id, fire_count, misfire_count,
|
||||
metadata, created_at, updated_at, created_by
|
||||
FROM scheduler.triggers
|
||||
WHERE tenant_id = @tenant_id AND name = @name
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "name", name);
|
||||
},
|
||||
MapTrigger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TriggerEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, description, job_type, job_payload, cron_expression, timezone,
|
||||
enabled, next_fire_at, last_fire_at, last_job_id, fire_count, misfire_count,
|
||||
metadata, created_at, updated_at, created_by
|
||||
FROM scheduler.triggers
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY name
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapTrigger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TriggerEntity>> GetDueTriggersAsync(int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, description, job_type, job_payload, cron_expression, timezone,
|
||||
enabled, next_fire_at, last_fire_at, last_job_id, fire_count, misfire_count,
|
||||
metadata, created_at, updated_at, created_by
|
||||
FROM scheduler.triggers
|
||||
WHERE enabled = TRUE AND next_fire_at <= NOW()
|
||||
ORDER BY next_fire_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<TriggerEntity>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapTrigger(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TriggerEntity> CreateAsync(TriggerEntity trigger, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scheduler.triggers (
|
||||
id, tenant_id, name, description, job_type, job_payload, cron_expression, timezone,
|
||||
enabled, next_fire_at, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @name, @description, @job_type, @job_payload::jsonb, @cron_expression, @timezone,
|
||||
@enabled, @next_fire_at, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
var id = trigger.Id == Guid.Empty ? Guid.NewGuid() : trigger.Id;
|
||||
await using var connection = await DataSource.OpenConnectionAsync(trigger.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "id", id);
|
||||
AddParameter(command, "tenant_id", trigger.TenantId);
|
||||
AddParameter(command, "name", trigger.Name);
|
||||
AddParameter(command, "description", trigger.Description);
|
||||
AddParameter(command, "job_type", trigger.JobType);
|
||||
AddJsonbParameter(command, "job_payload", trigger.JobPayload);
|
||||
AddParameter(command, "cron_expression", trigger.CronExpression);
|
||||
AddParameter(command, "timezone", trigger.Timezone);
|
||||
AddParameter(command, "enabled", trigger.Enabled);
|
||||
AddParameter(command, "next_fire_at", trigger.NextFireAt);
|
||||
AddJsonbParameter(command, "metadata", trigger.Metadata);
|
||||
AddParameter(command, "created_by", trigger.CreatedBy);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return MapTrigger(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateAsync(TriggerEntity trigger, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE scheduler.triggers
|
||||
SET name = @name,
|
||||
description = @description,
|
||||
job_type = @job_type,
|
||||
job_payload = @job_payload::jsonb,
|
||||
cron_expression = @cron_expression,
|
||||
timezone = @timezone,
|
||||
enabled = @enabled,
|
||||
next_fire_at = @next_fire_at,
|
||||
metadata = @metadata::jsonb
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
trigger.TenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", trigger.TenantId);
|
||||
AddParameter(cmd, "id", trigger.Id);
|
||||
AddParameter(cmd, "name", trigger.Name);
|
||||
AddParameter(cmd, "description", trigger.Description);
|
||||
AddParameter(cmd, "job_type", trigger.JobType);
|
||||
AddJsonbParameter(cmd, "job_payload", trigger.JobPayload);
|
||||
AddParameter(cmd, "cron_expression", trigger.CronExpression);
|
||||
AddParameter(cmd, "timezone", trigger.Timezone);
|
||||
AddParameter(cmd, "enabled", trigger.Enabled);
|
||||
AddParameter(cmd, "next_fire_at", trigger.NextFireAt);
|
||||
AddJsonbParameter(cmd, "metadata", trigger.Metadata);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RecordFireAsync(string tenantId, Guid triggerId, Guid jobId, DateTimeOffset? nextFireAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE scheduler.triggers
|
||||
SET last_fire_at = NOW(),
|
||||
last_job_id = @job_id,
|
||||
next_fire_at = @next_fire_at,
|
||||
fire_count = fire_count + 1
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", triggerId);
|
||||
AddParameter(cmd, "job_id", jobId);
|
||||
AddParameter(cmd, "next_fire_at", nextFireAt);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RecordMisfireAsync(string tenantId, Guid triggerId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE scheduler.triggers
|
||||
SET misfire_count = misfire_count + 1
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", triggerId);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetEnabledAsync(string tenantId, Guid id, bool enabled, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE scheduler.triggers
|
||||
SET enabled = @enabled
|
||||
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, "enabled", enabled);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM scheduler.triggers 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 TriggerEntity MapTrigger(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
JobType = reader.GetString(reader.GetOrdinal("job_type")),
|
||||
JobPayload = reader.GetString(reader.GetOrdinal("job_payload")),
|
||||
CronExpression = reader.GetString(reader.GetOrdinal("cron_expression")),
|
||||
Timezone = reader.GetString(reader.GetOrdinal("timezone")),
|
||||
Enabled = reader.GetBoolean(reader.GetOrdinal("enabled")),
|
||||
NextFireAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("next_fire_at")),
|
||||
LastFireAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("last_fire_at")),
|
||||
LastJobId = GetNullableGuid(reader, reader.GetOrdinal("last_job_id")),
|
||||
FireCount = reader.GetInt64(reader.GetOrdinal("fire_count")),
|
||||
MisfireCount = reader.GetInt32(reader.GetOrdinal("misfire_count")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for worker operations.
|
||||
/// </summary>
|
||||
public sealed class WorkerRepository : RepositoryBase<SchedulerDataSource>, IWorkerRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new worker repository.
|
||||
/// </summary>
|
||||
public WorkerRepository(SchedulerDataSource dataSource, ILogger<WorkerRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WorkerEntity?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, current_jobs,
|
||||
status, last_heartbeat_at, registered_at, metadata
|
||||
FROM scheduler.workers
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapWorker(reader) : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WorkerEntity>> ListAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, current_jobs,
|
||||
status, last_heartbeat_at, registered_at, metadata
|
||||
FROM scheduler.workers
|
||||
ORDER BY registered_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
var results = new List<WorkerEntity>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapWorker(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WorkerEntity>> ListByStatusAsync(string status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, current_jobs,
|
||||
status, last_heartbeat_at, registered_at, metadata
|
||||
FROM scheduler.workers
|
||||
WHERE status = @status
|
||||
ORDER BY registered_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "status", status);
|
||||
|
||||
var results = new List<WorkerEntity>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapWorker(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WorkerEntity>> GetByTenantIdAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, current_jobs,
|
||||
status, last_heartbeat_at, registered_at, metadata
|
||||
FROM scheduler.workers
|
||||
WHERE tenant_id = @tenant_id OR tenant_id IS NULL
|
||||
ORDER BY registered_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
|
||||
var results = new List<WorkerEntity>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapWorker(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WorkerEntity> UpsertAsync(WorkerEntity worker, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scheduler.workers (id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, metadata)
|
||||
VALUES (@id, @tenant_id, @hostname, @process_id, @job_types, @max_concurrent_jobs, @metadata::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
hostname = EXCLUDED.hostname,
|
||||
process_id = EXCLUDED.process_id,
|
||||
job_types = EXCLUDED.job_types,
|
||||
max_concurrent_jobs = EXCLUDED.max_concurrent_jobs,
|
||||
metadata = EXCLUDED.metadata,
|
||||
last_heartbeat_at = NOW(),
|
||||
status = 'active'
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "id", worker.Id);
|
||||
AddParameter(command, "tenant_id", worker.TenantId);
|
||||
AddParameter(command, "hostname", worker.Hostname);
|
||||
AddParameter(command, "process_id", worker.ProcessId);
|
||||
AddTextArrayParameter(command, "job_types", worker.JobTypes);
|
||||
AddParameter(command, "max_concurrent_jobs", worker.MaxConcurrentJobs);
|
||||
AddJsonbParameter(command, "metadata", worker.Metadata);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return MapWorker(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HeartbeatAsync(string id, int currentJobs, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE scheduler.workers
|
||||
SET last_heartbeat_at = NOW(), current_jobs = @current_jobs
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
AddParameter(command, "current_jobs", currentJobs);
|
||||
|
||||
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetStatusAsync(string id, string status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE scheduler.workers
|
||||
SET status = @status
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
AddParameter(command, "status", status);
|
||||
|
||||
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM scheduler.workers WHERE id = @id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
|
||||
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WorkerEntity>> GetStaleWorkersAsync(TimeSpan staleDuration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, current_jobs,
|
||||
status, last_heartbeat_at, registered_at, metadata
|
||||
FROM scheduler.workers
|
||||
WHERE status = 'active' AND last_heartbeat_at < NOW() - @stale_duration
|
||||
ORDER BY last_heartbeat_at
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "stale_duration", staleDuration);
|
||||
|
||||
var results = new List<WorkerEntity>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapWorker(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static WorkerEntity MapWorker(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetString(reader.GetOrdinal("id")),
|
||||
TenantId = GetNullableString(reader, reader.GetOrdinal("tenant_id")),
|
||||
Hostname = reader.GetString(reader.GetOrdinal("hostname")),
|
||||
ProcessId = reader.IsDBNull(reader.GetOrdinal("process_id")) ? null : reader.GetInt32(reader.GetOrdinal("process_id")),
|
||||
JobTypes = reader.IsDBNull(reader.GetOrdinal("job_types")) ? [] : reader.GetFieldValue<string[]>(reader.GetOrdinal("job_types")),
|
||||
MaxConcurrentJobs = reader.GetInt32(reader.GetOrdinal("max_concurrent_jobs")),
|
||||
CurrentJobs = reader.GetInt32(reader.GetOrdinal("current_jobs")),
|
||||
Status = reader.GetString(reader.GetOrdinal("status")),
|
||||
LastHeartbeatAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("last_heartbeat_at")),
|
||||
RegisteredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("registered_at")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata"))
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user