using System.Collections.Immutable; using System.Text.Json.Serialization; namespace StellaOps.Notify.Models; /// /// On-call schedule defining who is on-call at any given time. /// public sealed record NotifyOnCallSchedule { [JsonConstructor] public NotifyOnCallSchedule( string scheduleId, string tenantId, string name, string timeZone, ImmutableArray layers, ImmutableArray overrides, bool enabled = true, string? description = null, ImmutableDictionary? metadata = null, string? createdBy = null, DateTimeOffset? createdAt = null, string? updatedBy = null, DateTimeOffset? updatedAt = null) { ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId)); TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId)); Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name)); TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone)); Layers = layers.IsDefault ? ImmutableArray.Empty : layers; Overrides = overrides.IsDefault ? ImmutableArray.Empty : overrides; Enabled = enabled; Description = NotifyValidation.TrimToNull(description); Metadata = NotifyValidation.NormalizeStringDictionary(metadata); CreatedBy = NotifyValidation.TrimToNull(createdBy); CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow); UpdatedBy = NotifyValidation.TrimToNull(updatedBy); UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt); } public static NotifyOnCallSchedule Create( string scheduleId, string tenantId, string name, string timeZone, IEnumerable? layers = null, IEnumerable? overrides = null, bool enabled = true, string? description = null, IEnumerable>? metadata = null, string? createdBy = null, DateTimeOffset? createdAt = null, string? updatedBy = null, DateTimeOffset? updatedAt = null) { return new NotifyOnCallSchedule( scheduleId, tenantId, name, timeZone, layers?.ToImmutableArray() ?? ImmutableArray.Empty, overrides?.ToImmutableArray() ?? ImmutableArray.Empty, enabled, description, ToImmutableDictionary(metadata), createdBy, createdAt, updatedBy, updatedAt); } public string ScheduleId { get; } public string TenantId { get; } public string Name { get; } /// /// IANA time zone for the schedule (e.g., "America/New_York"). /// public string TimeZone { get; } /// /// Rotation layers that make up this schedule. /// Multiple layers are combined to determine final on-call. /// public ImmutableArray Layers { get; } /// /// Temporary overrides (e.g., vacation coverage). /// public ImmutableArray Overrides { get; } public bool Enabled { get; } public string? Description { get; } public ImmutableDictionary Metadata { get; } public string? CreatedBy { get; } public DateTimeOffset CreatedAt { get; } public string? UpdatedBy { get; } public DateTimeOffset UpdatedAt { get; } private static ImmutableDictionary? ToImmutableDictionary(IEnumerable>? pairs) { if (pairs is null) { return null; } var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); foreach (var (key, value) in pairs) { builder[key] = value; } return builder.ToImmutable(); } } /// /// A layer in an on-call schedule representing a rotation. /// public sealed record NotifyOnCallLayer { [JsonConstructor] public NotifyOnCallLayer( string layerId, string name, int priority, NotifyRotationType rotationType, TimeSpan rotationInterval, DateTimeOffset rotationStartsAt, ImmutableArray participants, NotifyOnCallRestriction? restrictions = null) { LayerId = NotifyValidation.EnsureNotNullOrWhiteSpace(layerId, nameof(layerId)); Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name)); Priority = priority; RotationType = rotationType; RotationInterval = rotationInterval > TimeSpan.Zero ? rotationInterval : TimeSpan.FromDays(7); RotationStartsAt = NotifyValidation.EnsureUtc(rotationStartsAt); Participants = participants.IsDefault ? ImmutableArray.Empty : participants; Restrictions = restrictions; } public static NotifyOnCallLayer Create( string layerId, string name, int priority, NotifyRotationType rotationType, TimeSpan rotationInterval, DateTimeOffset rotationStartsAt, IEnumerable? participants = null, NotifyOnCallRestriction? restrictions = null) { return new NotifyOnCallLayer( layerId, name, priority, rotationType, rotationInterval, rotationStartsAt, participants?.ToImmutableArray() ?? ImmutableArray.Empty, restrictions); } public string LayerId { get; } public string Name { get; } /// /// Higher priority layers take precedence when determining who is on-call. /// public int Priority { get; } public NotifyRotationType RotationType { get; } /// /// Duration of each rotation (e.g., 1 week). /// public TimeSpan RotationInterval { get; } /// /// When the rotation schedule started. /// public DateTimeOffset RotationStartsAt { get; } /// /// Participants in the rotation. /// public ImmutableArray Participants { get; } /// /// Optional time restrictions for when this layer is active. /// public NotifyOnCallRestriction? Restrictions { get; } } /// /// Participant in an on-call rotation. /// public sealed record NotifyOnCallParticipant { [JsonConstructor] public NotifyOnCallParticipant( string userId, string? name = null, string? email = null, string? phone = null, ImmutableArray contactMethods = default) { UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId)); Name = NotifyValidation.TrimToNull(name); Email = NotifyValidation.TrimToNull(email); Phone = NotifyValidation.TrimToNull(phone); ContactMethods = contactMethods.IsDefault ? ImmutableArray.Empty : contactMethods; } public static NotifyOnCallParticipant Create( string userId, string? name = null, string? email = null, string? phone = null, IEnumerable? contactMethods = null) { return new NotifyOnCallParticipant( userId, name, email, phone, contactMethods?.ToImmutableArray() ?? ImmutableArray.Empty); } public string UserId { get; } public string? Name { get; } public string? Email { get; } public string? Phone { get; } public ImmutableArray ContactMethods { get; } } /// /// Contact method for a participant. /// public sealed record NotifyContactMethod { [JsonConstructor] public NotifyContactMethod( NotifyContactMethodType type, string address, int priority = 0, bool enabled = true) { Type = type; Address = NotifyValidation.EnsureNotNullOrWhiteSpace(address, nameof(address)); Priority = priority; Enabled = enabled; } public NotifyContactMethodType Type { get; } public string Address { get; } public int Priority { get; } public bool Enabled { get; } } /// /// Type of contact method. /// [JsonConverter(typeof(JsonStringEnumConverter))] public enum NotifyContactMethodType { Email, Sms, Phone, Slack, Teams, Webhook, InAppInbox, PagerDuty, OpsGenie } /// /// Type of rotation. /// [JsonConverter(typeof(JsonStringEnumConverter))] public enum NotifyRotationType { /// /// Daily rotation. /// Daily, /// /// Weekly rotation. /// Weekly, /// /// Custom interval rotation. /// Custom } /// /// Time restrictions for when an on-call layer is active. /// public sealed record NotifyOnCallRestriction { [JsonConstructor] public NotifyOnCallRestriction( NotifyRestrictionType type, ImmutableArray timeRanges) { Type = type; TimeRanges = timeRanges.IsDefault ? ImmutableArray.Empty : timeRanges; } public static NotifyOnCallRestriction Create( NotifyRestrictionType type, IEnumerable? timeRanges = null) { return new NotifyOnCallRestriction( type, timeRanges?.ToImmutableArray() ?? ImmutableArray.Empty); } public NotifyRestrictionType Type { get; } public ImmutableArray TimeRanges { get; } } /// /// Type of restriction. /// [JsonConverter(typeof(JsonStringEnumConverter))] public enum NotifyRestrictionType { /// /// Restrictions apply daily. /// DailyRestriction, /// /// Restrictions apply weekly on specific days. /// WeeklyRestriction } /// /// A time range for restrictions. /// public sealed record NotifyTimeRange { [JsonConstructor] public NotifyTimeRange( DayOfWeek? dayOfWeek, TimeOnly startTime, TimeOnly endTime) { DayOfWeek = dayOfWeek; StartTime = startTime; EndTime = endTime; } /// /// Day of week (null for daily restrictions). /// public DayOfWeek? DayOfWeek { get; } public TimeOnly StartTime { get; } public TimeOnly EndTime { get; } } /// /// Temporary override for an on-call schedule. /// public sealed record NotifyOnCallOverride { [JsonConstructor] public NotifyOnCallOverride( string overrideId, string userId, DateTimeOffset startsAt, DateTimeOffset endsAt, string? reason = null, string? createdBy = null, DateTimeOffset? createdAt = null) { OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId)); UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId)); StartsAt = NotifyValidation.EnsureUtc(startsAt); EndsAt = NotifyValidation.EnsureUtc(endsAt); Reason = NotifyValidation.TrimToNull(reason); CreatedBy = NotifyValidation.TrimToNull(createdBy); CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow); if (EndsAt <= StartsAt) { throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt)); } } public static NotifyOnCallOverride Create( string overrideId, string userId, DateTimeOffset startsAt, DateTimeOffset endsAt, string? reason = null, string? createdBy = null, DateTimeOffset? createdAt = null) { return new NotifyOnCallOverride( overrideId, userId, startsAt, endsAt, reason, createdBy, createdAt); } public string OverrideId { get; } /// /// User who will be on-call during this override. /// public string UserId { get; } public DateTimeOffset StartsAt { get; } public DateTimeOffset EndsAt { get; } public string? Reason { get; } public string? CreatedBy { get; } public DateTimeOffset CreatedAt { get; } /// /// Checks if the override is active at the specified time. /// public bool IsActiveAt(DateTimeOffset timestamp) => timestamp >= StartsAt && timestamp < EndsAt; } /// /// Result of resolving who is currently on-call. /// public sealed record NotifyOnCallResolution { public NotifyOnCallResolution( string scheduleId, DateTimeOffset evaluatedAt, ImmutableArray onCallUsers, string? sourceLayer = null, string? sourceOverride = null) { ScheduleId = scheduleId; EvaluatedAt = evaluatedAt; OnCallUsers = onCallUsers.IsDefault ? ImmutableArray.Empty : onCallUsers; SourceLayer = sourceLayer; SourceOverride = sourceOverride; } public string ScheduleId { get; } public DateTimeOffset EvaluatedAt { get; } public ImmutableArray OnCallUsers { get; } /// /// The layer that provided the on-call user (if from rotation). /// public string? SourceLayer { get; } /// /// The override that provided the on-call user (if from override). /// public string? SourceOverride { get; } }