From d64072a3347f53ffcfda370bbd600f5270cd9fac Mon Sep 17 00:00:00 2001 From: David Friedel Date: Thu, 25 Dec 2025 09:09:12 +0000 Subject: [PATCH] Initial commit: IronServices.Client SDK Unified client for Iron Services APIs with: - Session-based authentication - Automatic token persistence - Access to IronLicensing, IronNotify, and IronTelemetry APIs - Generic HTTP methods for custom endpoints --- .gitignore | 6 + ApiModels.cs | 278 +++++++++++++++++++++++++ ITokenStorage.cs | 121 +++++++++++ IronServices.Client.csproj | 21 ++ IronServicesClient.cs | 411 +++++++++++++++++++++++++++++++++++++ LICENSE | 21 ++ LicensingApi.cs | 163 +++++++++++++++ Models.cs | 108 ++++++++++ NotifyApi.cs | 279 +++++++++++++++++++++++++ README.md | 237 +++++++++++++++++++++ TelemetryApi.cs | 215 +++++++++++++++++++ 11 files changed, 1860 insertions(+) create mode 100644 .gitignore create mode 100644 ApiModels.cs create mode 100644 ITokenStorage.cs create mode 100755 IronServices.Client.csproj create mode 100755 IronServicesClient.cs create mode 100644 LICENSE create mode 100644 LicensingApi.cs create mode 100644 Models.cs create mode 100644 NotifyApi.cs create mode 100644 README.md create mode 100644 TelemetryApi.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..776e6f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +*.user +*.suo +.vs/ +*.DotSettings.user diff --git a/ApiModels.cs b/ApiModels.cs new file mode 100644 index 0000000..a813a44 --- /dev/null +++ b/ApiModels.cs @@ -0,0 +1,278 @@ +namespace IronServices.Client; + +#region Licensing Models + +public class LicensingDashboardStats +{ + public int TotalLicenses { get; set; } + public int ActiveLicenses { get; set; } + public int ExpiringThisMonth { get; set; } + public int NewThisMonth { get; set; } + public decimal MonthlyRevenue { get; set; } + public int TotalProducts { get; set; } +} + +public class LicenseDto +{ + public Guid Id { get; set; } + public string LicenseKey { get; set; } = ""; + public string Status { get; set; } = ""; + public string? CustomerEmail { get; set; } + public string? CustomerName { get; set; } + public string ProductName { get; set; } = ""; + public string? ProductSlug { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public DateTime? LastActivatedAt { get; set; } + public int MaxActivations { get; set; } + public int CurrentActivations { get; set; } + public string? SuspensionReason { get; set; } + public string? RevocationReason { get; set; } +} + +public class ActivityItem +{ + public Guid Id { get; set; } + public string Type { get; set; } = ""; + public string Description { get; set; } = ""; + public DateTime CreatedAt { get; set; } + public string? EntityType { get; set; } + public Guid? EntityId { get; set; } + public string? UserEmail { get; set; } + public Dictionary? Metadata { get; set; } +} + +public class TicketDto +{ + public Guid Id { get; set; } + public string Subject { get; set; } = ""; + public string Status { get; set; } = ""; + public string Priority { get; set; } = ""; + public string? CustomerEmail { get; set; } + public string? CustomerName { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public int MessageCount { get; set; } +} + +public class TicketDetailDto : TicketDto +{ + public List Messages { get; set; } = []; +} + +public class TicketMessageDto +{ + public Guid Id { get; set; } + public string Content { get; set; } = ""; + public string SenderType { get; set; } = ""; + public string? SenderEmail { get; set; } + public string? SenderName { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class UpdateProfileRequest +{ + public string? DisplayName { get; set; } + public string? CurrentPassword { get; set; } + public string? NewPassword { get; set; } +} + +#endregion + +#region Notify Models + +public class NotificationDto +{ + public Guid Id { get; set; } + public Guid EventId { get; set; } + public string Type { get; set; } = ""; + public string Title { get; set; } = ""; + public string Message { get; set; } = ""; + public string Severity { get; set; } = "Info"; + public string? AppSlug { get; set; } + public string? AppName { get; set; } + public string? ActionUrl { get; set; } + public string? EntityId { get; set; } + public bool IsRead { get; set; } + public bool IsAcknowledged { get; set; } + public bool IsResolved { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? ReadAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public DateTime? ResolvedAt { get; set; } + public string? AcknowledgedBy { get; set; } + public string? ResolvedBy { get; set; } + public Dictionary? Metadata { get; set; } +} + +public class OnCallStatus +{ + public bool IsOnCall { get; set; } + public string? OnCallUserName { get; set; } + public string? OnCallUserEmail { get; set; } + public DateTime? OnCallUntil { get; set; } + public string? NextOnCallUserName { get; set; } + public DateTime? NextShiftStart { get; set; } +} + +public class ScheduleSlot +{ + public Guid UserId { get; set; } + public string UserName { get; set; } = ""; + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public bool IsCurrent { get; set; } +} + +public class TeamMember +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; + public string? PhoneNumber { get; set; } + public string? Role { get; set; } + public Guid? TeamId { get; set; } + public string? TeamName { get; set; } + public bool IsOnCall { get; set; } + public int DeviceCount { get; set; } + public DateTime CreatedAt { get; set; } + + // Alias for compatibility + public Guid UserId => Id; +} + +public class AppSubscription +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public string Slug { get; set; } = ""; + public string? Description { get; set; } + public string? WebhookUrl { get; set; } + public string? WebhookSecret { get; set; } + public bool IsActive { get; set; } + public bool IsMuted { get; set; } + public DateTime? MutedUntil { get; set; } + public int UnreadCount { get; set; } + public int TotalNotifications { get; set; } + public DateTime? LastEventAt { get; set; } + public string SeverityFilter { get; set; } = "All"; + public DateTime CreatedAt { get; set; } + + // Aliases for compatibility + public string AppSlug => Slug; + public string AppName => Name; +} + +public class NotificationPreferences +{ + public bool PushEnabled { get; set; } = true; + public bool SoundEnabled { get; set; } = true; + public bool VibrationEnabled { get; set; } = true; + public string MinimumSeverity { get; set; } = "Info"; + public TimeSpan? QuietHoursStart { get; set; } + public TimeSpan? QuietHoursEnd { get; set; } +} + +#endregion + +#region Telemetry Models + +public class TelemetryDashboardStats +{ + public int TotalErrors { get; set; } + public int OpenIssues { get; set; } + public int ResolvedToday { get; set; } + public double ErrorRate { get; set; } + public int ActiveProjects { get; set; } + public List ErrorTrends { get; set; } = []; + public List TopIssues { get; set; } = []; +} + +public class ErrorTrend +{ + public DateTime Date { get; set; } + public int Count { get; set; } +} + +public class TopIssue +{ + public string Id { get; set; } = ""; + public string Title { get; set; } = ""; + public int EventCount { get; set; } + public string ProjectName { get; set; } = ""; +} + +public class IssueDto +{ + public Guid Id { get; set; } + public string Title { get; set; } = ""; + public string? Culprit { get; set; } + public string Level { get; set; } = "error"; + public string Severity { get; set; } = "error"; + public string Status { get; set; } = "unresolved"; + public int EventCount { get; set; } + public int UserCount { get; set; } + public DateTime FirstSeen { get; set; } + public DateTime LastSeen { get; set; } + public Guid? ProjectId { get; set; } + public string? ProjectName { get; set; } + public string? ProjectSlug { get; set; } + public bool IsIgnored { get; set; } + public Guid? AssignedToUserId { get; set; } + public string? AssignedTo { get; set; } +} + +public class SignalDto +{ + public Guid Id { get; set; } + public string Type { get; set; } = ""; + public string Title { get; set; } = ""; + public string? Message { get; set; } + public string Level { get; set; } = "info"; + public DateTime CreatedAt { get; set; } + public string? ProjectName { get; set; } + public string? IssueId { get; set; } + public Dictionary? Metadata { get; set; } +} + +public class TelemetryProjectDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public string Slug { get; set; } = ""; + public string? Platform { get; set; } + public string Dsn { get; set; } = ""; + public bool IsActive { get; set; } + public bool StackTraceGroupingEnabled { get; set; } + public bool JourneyTrackingEnabled { get; set; } + public bool SourceMapEnabled { get; set; } + public int SampleRate { get; set; } + public Guid? ProductId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} + +public class AlertPreferences +{ + public bool PushEnabled { get; set; } = true; + public bool EmailEnabled { get; set; } = true; + public string MinimumLevel { get; set; } = "error"; + public int SpikeThreshold { get; set; } = 10; + public bool NotifyOnNewIssue { get; set; } = true; + public bool NotifyOnRegression { get; set; } = true; + public bool NotifyOnSpike { get; set; } = true; + public TimeSpan? QuietHoursStart { get; set; } + public TimeSpan? QuietHoursEnd { get; set; } +} + +#endregion + +#region Internal Response Types + +internal class UnreadCountResponse +{ + public int Count { get; set; } + public int UnreadCount { get; set; } +} + +#endregion diff --git a/ITokenStorage.cs b/ITokenStorage.cs new file mode 100644 index 0000000..dfc49eb --- /dev/null +++ b/ITokenStorage.cs @@ -0,0 +1,121 @@ +namespace IronServices.Client; + +/// +/// Interface for storing authentication tokens. +/// Implement this for platform-specific secure storage (e.g., MAUI SecureStorage, Windows Credential Manager). +/// +public interface ITokenStorage +{ + /// + /// Save the session token. + /// + Task SaveTokenAsync(string token, DateTime? expiresAt); + + /// + /// Get the stored session token. + /// + Task<(string? Token, DateTime? ExpiresAt)> GetTokenAsync(); + + /// + /// Clear the stored token. + /// + Task ClearTokenAsync(); +} + +/// +/// In-memory token storage (not persisted across app restarts). +/// +public class InMemoryTokenStorage : ITokenStorage +{ + private string? _token; + private DateTime? _expiresAt; + + public Task SaveTokenAsync(string token, DateTime? expiresAt) + { + _token = token; + _expiresAt = expiresAt; + return Task.CompletedTask; + } + + public Task<(string? Token, DateTime? ExpiresAt)> GetTokenAsync() + { + return Task.FromResult((_token, _expiresAt)); + } + + public Task ClearTokenAsync() + { + _token = null; + _expiresAt = null; + return Task.CompletedTask; + } +} + +/// +/// File-based token storage (for console apps, testing). +/// +public class FileTokenStorage : ITokenStorage +{ + private readonly string _filePath; + + public FileTokenStorage(string? filePath = null) + { + _filePath = filePath ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "IronServices", + "session.json"); + } + + public async Task SaveTokenAsync(string token, DateTime? expiresAt) + { + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var data = new { token, expiresAt = expiresAt?.ToString("O") }; + var json = System.Text.Json.JsonSerializer.Serialize(data); + await File.WriteAllTextAsync(_filePath, json); + } + + public async Task<(string? Token, DateTime? ExpiresAt)> GetTokenAsync() + { + if (!File.Exists(_filePath)) + { + return (null, null); + } + + try + { + var json = await File.ReadAllTextAsync(_filePath); + var data = System.Text.Json.JsonSerializer.Deserialize(json); + + DateTime? expiresAt = null; + if (!string.IsNullOrEmpty(data?.ExpiresAt)) + { + expiresAt = DateTime.Parse(data.ExpiresAt, null, System.Globalization.DateTimeStyles.RoundtripKind); + } + + return (data?.Token, expiresAt); + } + catch + { + return (null, null); + } + } + + public Task ClearTokenAsync() + { + if (File.Exists(_filePath)) + { + File.Delete(_filePath); + } + return Task.CompletedTask; + } + + private class TokenData + { + public string? Token { get; set; } + public string? ExpiresAt { get; set; } + } +} diff --git a/IronServices.Client.csproj b/IronServices.Client.csproj new file mode 100755 index 0000000..7c08e8e --- /dev/null +++ b/IronServices.Client.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + latest + IronServices.Client + IronServices.Client + Client library for IronServices API (IronLicensing, IronTelemetry and IronNotify) + David H Friedel Jr + MarketAlly + IronServices.Client + false + + + + + + + diff --git a/IronServicesClient.cs b/IronServicesClient.cs new file mode 100755 index 0000000..86f790f --- /dev/null +++ b/IronServicesClient.cs @@ -0,0 +1,411 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IronServices.Client; + +/// +/// Client for IronServices APIs with session-based authentication. +/// Handles login, token storage, and automatic bearer token attachment across multiple services. +/// +public class IronServicesClient : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly ITokenStorage _tokenStorage; + private readonly JsonSerializerOptions _jsonOptions; + private readonly IronServicesClientOptions _options; + private string? _sessionToken; + private DateTime? _expiresAt; + private bool _disposed; + + // Lazy-initialized API services + private LicensingApi? _licensing; + private NotifyApi? _notify; + private TelemetryApi? _telemetry; + + /// + /// IronLicensing API operations. + /// + public LicensingApi Licensing => _licensing ??= new LicensingApi(this, _options.LicensingUrl); + + /// + /// IronNotify API operations. + /// + public NotifyApi Notify => _notify ??= new NotifyApi(this, _options.NotifyUrl); + + /// + /// IronTelemetry API operations. + /// + public TelemetryApi Telemetry => _telemetry ??= new TelemetryApi(this, _options.TelemetryUrl); + + /// + /// Creates a new IronServices client with multiple service URLs. + /// + public IronServicesClient(IronServicesClientOptions options, ITokenStorage? tokenStorage = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + + _tokenStorage = tokenStorage ?? new InMemoryTokenStorage(); + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + // Try to restore session from storage + RestoreSession(); + } + + /// + /// Creates a new IronServices client with a single base URL (legacy compatibility). + /// All services will use this URL. + /// + public IronServicesClient(string baseUrl, ITokenStorage? tokenStorage = null) + : this(new IronServicesClientOptions + { + LicensingUrl = baseUrl, + NotifyUrl = baseUrl, + TelemetryUrl = baseUrl + }, tokenStorage) + { + } + + /// + /// Whether the client has a valid session token. + /// + public bool IsAuthenticated => !string.IsNullOrEmpty(_sessionToken) && + (_expiresAt == null || _expiresAt > DateTime.UtcNow); + + /// + /// Current session expiration time (UTC). + /// + public DateTime? SessionExpiresAt => _expiresAt; + + /// + /// The current session token (for advanced scenarios). + /// + public string? SessionToken => _sessionToken; + + /// + /// Login with email and password. + /// + public async Task LoginAsync(string email, string password, CancellationToken cancellationToken = default) + { + var request = new { email, password }; + var url = BuildUrl(_options.LicensingUrl, "api/v1/users/login"); + + var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var error = await ReadErrorAsync(response, cancellationToken); + return new LoginResult { Success = false, Error = error }; + } + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); + + if (result == null || string.IsNullOrEmpty(result.SessionToken)) + { + return new LoginResult { Success = false, Error = "Invalid response from server" }; + } + + // Store session + await SetSessionAsync(result.SessionToken, result.ExpiresAt); + + return new LoginResult + { + Success = true, + UserId = result.UserId, + Email = result.Email, + DisplayName = result.DisplayName, + Role = result.Role, + ExpiresAt = result.ExpiresAt + }; + } + + /// + /// Register a new account. + /// + public async Task RegisterAsync(string email, string password, string? displayName = null, CancellationToken cancellationToken = default) + { + var request = new { email, password, displayName }; + var url = BuildUrl(_options.LicensingUrl, "api/v1/users/register"); + + var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var error = await ReadErrorAsync(response, cancellationToken); + return new RegisterResult { Success = false, Error = error }; + } + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); + + return new RegisterResult + { + Success = true, + UserId = result?.User?.Id, + Email = result?.User?.Email, + ApiKey = result?.ApiKey + }; + } + + /// + /// Logout and clear session. + /// + public async Task LogoutAsync(CancellationToken cancellationToken = default) + { + if (IsAuthenticated) + { + try + { + var url = BuildUrl(_options.LicensingUrl, "api/v1/users/logout"); + await _httpClient.PostAsync(url, null, cancellationToken); + } + catch + { + // Ignore logout errors + } + } + + await ClearSessionAsync(); + } + + /// + /// Get current user profile. + /// + public async Task GetProfileAsync(CancellationToken cancellationToken = default) + { + EnsureAuthenticated(); + + var url = BuildUrl(_options.LicensingUrl, "api/v1/users/me"); + var response = await _httpClient.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + await ClearSessionAsync(); + } + return null; + } + + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); + } + + /// + /// Verify email with 6-digit code. + /// + public async Task VerifyEmailAsync(string email, string code, CancellationToken cancellationToken = default) + { + var request = new { email, code }; + var url = BuildUrl(_options.LicensingUrl, "api/v1/users/verify-email"); + + var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var error = await ReadErrorAsync(response, cancellationToken); + return new VerifyEmailResult { Success = false, Error = error }; + } + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); + + if (result?.SessionToken != null) + { + await SetSessionAsync(result.SessionToken, null); + } + + return new VerifyEmailResult + { + Success = true, + Message = result?.Message ?? "Email verified" + }; + } + + /// + /// Request password reset email. + /// + public async Task RequestPasswordResetAsync(string email, CancellationToken cancellationToken = default) + { + var request = new { email }; + var url = BuildUrl(_options.LicensingUrl, "api/v1/users/forgot-password"); + var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken); + return response.IsSuccessStatusCode; + } + + /// + /// Reset password with token. + /// + public async Task ResetPasswordAsync(string token, string newPassword, CancellationToken cancellationToken = default) + { + var request = new { token, newPassword }; + var url = BuildUrl(_options.LicensingUrl, "api/v1/users/reset-password"); + + var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var error = await ReadErrorAsync(response, cancellationToken); + return new ResetPasswordResult { Success = false, Error = error }; + } + + return new ResetPasswordResult { Success = true }; + } + + /// + /// Make an authenticated GET request to a specific URL. + /// + public async Task GetAsync(string fullUrl, CancellationToken cancellationToken = default) + { + EnsureAuthenticated(); + var response = await _httpClient.GetAsync(fullUrl, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"GET {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}"); + } + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); + } + + /// + /// Make an authenticated POST request to a specific URL. + /// + public async Task PostAsync(string fullUrl, TRequest data, CancellationToken cancellationToken = default) + { + EnsureAuthenticated(); + var response = await _httpClient.PostAsJsonAsync(fullUrl, data, _jsonOptions, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"POST {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}"); + } + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); + } + + /// + /// Make an authenticated PUT request to a specific URL. + /// + public async Task PutAsync(string fullUrl, TRequest data, CancellationToken cancellationToken = default) + { + EnsureAuthenticated(); + var response = await _httpClient.PutAsJsonAsync(fullUrl, data, _jsonOptions, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"PUT {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}"); + } + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); + } + + /// + /// Make an authenticated DELETE request to a specific URL. + /// + public async Task DeleteAsync(string fullUrl, CancellationToken cancellationToken = default) + { + EnsureAuthenticated(); + var response = await _httpClient.DeleteAsync(fullUrl, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"DELETE {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}"); + } + } + + /// + /// Build a full URL from a base URL and endpoint. + /// + internal static string BuildUrl(string baseUrl, string endpoint) + { + return baseUrl.TrimEnd('/') + "/" + endpoint.TrimStart('/'); + } + + private async Task SetSessionAsync(string token, DateTime? expiresAt) + { + _sessionToken = token; + _expiresAt = expiresAt; + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _sessionToken); + await _tokenStorage.SaveTokenAsync(_sessionToken, _expiresAt); + } + + private void RestoreSession() + { + var (token, expiresAt) = _tokenStorage.GetTokenAsync().GetAwaiter().GetResult(); + + if (!string.IsNullOrEmpty(token) && (expiresAt == null || expiresAt > DateTime.UtcNow)) + { + _sessionToken = token; + _expiresAt = expiresAt; + _httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _sessionToken); + } + } + + private async Task ClearSessionAsync() + { + _sessionToken = null; + _expiresAt = null; + _httpClient.DefaultRequestHeaders.Authorization = null; + await _tokenStorage.ClearTokenAsync(); + } + + private void EnsureAuthenticated() + { + if (!IsAuthenticated) + { + throw new InvalidOperationException("Not authenticated. Call LoginAsync first."); + } + } + + private async Task ReadErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var error = JsonSerializer.Deserialize(content, _jsonOptions); + return error?.Error ?? $"Request failed with status {response.StatusCode}"; + } + catch + { + return $"Request failed with status {response.StatusCode}"; + } + } + + public void Dispose() + { + if (!_disposed) + { + _httpClient.Dispose(); + _disposed = true; + } + } +} + +/// +/// Configuration options for IronServicesClient. +/// +public class IronServicesClientOptions +{ + /// + /// Base URL for IronLicensing API (also used for auth/user operations). + /// Default: https://ironlicensing.com + /// + public string LicensingUrl { get; set; } = "https://ironlicensing.com"; + + /// + /// Base URL for IronNotify API. + /// Default: https://ironnotify.com + /// + public string NotifyUrl { get; set; } = "https://ironnotify.com"; + + /// + /// Base URL for IronTelemetry API. + /// Default: https://irontelemetry.com + /// + public string TelemetryUrl { get; set; } = "https://irontelemetry.com"; +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..79f68de --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 IronServices + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LicensingApi.cs b/LicensingApi.cs new file mode 100644 index 0000000..8032757 --- /dev/null +++ b/LicensingApi.cs @@ -0,0 +1,163 @@ +namespace IronServices.Client; + +/// +/// API client for IronLicensing operations. +/// Access via IronServicesClient.Licensing +/// +public class LicensingApi +{ + private readonly IronServicesClient _client; + private readonly string _baseUrl; + + internal LicensingApi(IronServicesClient client, string baseUrl) + { + _client = client; + _baseUrl = baseUrl; + } + + private string Url(string endpoint) => IronServicesClient.BuildUrl(_baseUrl, endpoint); + + /// + /// Get dashboard statistics. + /// + public async Task GetDashboardStatsAsync(CancellationToken ct = default) + { + return await _client.GetAsync(Url("api/v1/dashboard/stats"), ct) + ?? new LicensingDashboardStats(); + } + + /// + /// Get recent activity from audit logs. + /// + public async Task> GetRecentActivityAsync(int limit = 50, CancellationToken ct = default) + { + return await _client.GetAsync>(Url($"api/v1/admin/audit?limit={limit}"), ct) ?? []; + } + + /// + /// Get licenses with optional filtering. + /// + public async Task> GetLicensesAsync(string? status = null, Guid? productId = null, int page = 1, int limit = 50, CancellationToken ct = default) + { + var query = new List { $"page={page}", $"limit={limit}" }; + if (!string.IsNullOrEmpty(status)) query.Add($"status={status}"); + if (productId.HasValue) query.Add($"productId={productId}"); + var queryString = "?" + string.Join("&", query); + + return await _client.GetAsync>(Url($"api/v1/admin/licenses{queryString}"), ct) ?? []; + } + + /// + /// Get a specific license by ID. + /// + public async Task GetLicenseAsync(Guid id, CancellationToken ct = default) + { + return await _client.GetAsync(Url($"api/v1/admin/licenses/{id}"), ct); + } + + /// + /// Create a new license. + /// + public async Task CreateLicenseAsync(CreateLicenseRequest request, CancellationToken ct = default) + { + return await _client.PostAsync(Url("api/v1/admin/licenses"), request, ct); + } + + /// + /// Suspend a license. + /// + public async Task SuspendLicenseAsync(Guid id, string? reason = null, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/admin/licenses/{id}/suspend"), new { reason }, ct); + } + + /// + /// Revoke a license. + /// + public async Task RevokeLicenseAsync(Guid id, string? reason = null, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/admin/licenses/{id}/revoke"), new { reason }, ct); + } + + /// + /// Reactivate a suspended license. + /// + public async Task ReactivateLicenseAsync(Guid id, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/admin/licenses/{id}/reactivate"), new { }, ct); + } + + /// + /// Resend activation email for a license. + /// + public async Task ResendActivationEmailAsync(Guid id, CancellationToken ct = default) + { + await _client.PostAsync(Url($"api/v1/admin/licenses/{id}/resend-activation"), new { }, ct); + } + + /// + /// Update user profile. + /// + public async Task UpdateProfileAsync(UpdateProfileRequest request, CancellationToken ct = default) + { + await _client.PutAsync(Url("api/v1/profile"), request, ct); + } + + /// + /// Get tickets with optional status filter. + /// + public async Task> GetTicketsAsync(string? status = null, CancellationToken ct = default) + { + var query = !string.IsNullOrEmpty(status) ? $"?status={status}" : ""; + return await _client.GetAsync>(Url($"api/v1/tickets{query}"), ct) ?? []; + } + + /// + /// Get a specific ticket with messages. + /// + public async Task GetTicketAsync(Guid id, CancellationToken ct = default) + { + return await _client.GetAsync(Url($"api/v1/tickets/{id}"), ct); + } + + /// + /// Create a new ticket. + /// + public async Task CreateTicketAsync(string subject, string message, string? category = null, CancellationToken ct = default) + { + return await _client.PostAsync(Url("api/v1/tickets"), new { subject, message, category }, ct); + } + + /// + /// Reply to a ticket. + /// + public async Task ReplyToTicketAsync(Guid ticketId, string content, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/tickets/{ticketId}/messages"), new { content }, ct); + } + + /// + /// Close a ticket. + /// + public async Task CloseTicketAsync(Guid id, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/tickets/{id}/close"), new { }, ct); + } + + /// + /// Reopen a closed ticket. + /// + public async Task ReopenTicketAsync(Guid id, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/tickets/{id}/reopen"), new { }, ct); + } +} + +public class CreateLicenseRequest +{ + public Guid ProductId { get; set; } + public string? Email { get; set; } + public string? TierId { get; set; } + public int? MaxActivations { get; set; } + public DateTime? ExpiresAt { get; set; } +} diff --git a/Models.cs b/Models.cs new file mode 100644 index 0000000..ed76e2f --- /dev/null +++ b/Models.cs @@ -0,0 +1,108 @@ +namespace IronServices.Client; + +#region API Response DTOs + +internal class LoginResponse +{ + public string? UserId { get; set; } + public string? Email { get; set; } + public string? DisplayName { get; set; } + public bool EmailVerified { get; set; } + public string? SubscriptionTier { get; set; } + public string? Role { get; set; } + public string? SessionToken { get; set; } + public DateTime? ExpiresAt { get; set; } +} + +internal class RegisterResponse +{ + public UserInfo? User { get; set; } + public string? ApiKey { get; set; } +} + +internal class UserInfo +{ + public string? Id { get; set; } + public string? TenantId { get; set; } + public string? Email { get; set; } + public string? DisplayName { get; set; } + public bool EmailVerified { get; set; } + public string? Role { get; set; } +} + +internal class VerifyEmailResponse +{ + public string? Message { get; set; } + public string? SessionToken { get; set; } + public UserInfo? User { get; set; } +} + +internal class ErrorResponse +{ + public string? Error { get; set; } +} + +#endregion + +#region Public Result Types + +/// +/// Result of a login attempt. +/// +public class LoginResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public string? UserId { get; set; } + public string? Email { get; set; } + public string? DisplayName { get; set; } + public string? Role { get; set; } + public DateTime? ExpiresAt { get; set; } +} + +/// +/// Result of a registration attempt. +/// +public class RegisterResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public string? UserId { get; set; } + public string? Email { get; set; } + public string? ApiKey { get; set; } +} + +/// +/// Result of email verification. +/// +public class VerifyEmailResult +{ + public bool Success { get; set; } + public string? Error { get; set; } + public string? Message { get; set; } +} + +/// +/// Result of password reset. +/// +public class ResetPasswordResult +{ + public bool Success { get; set; } + public string? Error { get; set; } +} + +/// +/// User profile information. +/// +public class UserProfile +{ + public string? UserId { get; set; } + public string? Email { get; set; } + public string? DisplayName { get; set; } + public bool EmailVerified { get; set; } + public string? Role { get; set; } + public string? SubscriptionPlan { get; set; } + public DateTime? CreatedAt { get; set; } +} + +#endregion diff --git a/NotifyApi.cs b/NotifyApi.cs new file mode 100644 index 0000000..a6e6d22 --- /dev/null +++ b/NotifyApi.cs @@ -0,0 +1,279 @@ +namespace IronServices.Client; + +/// +/// API client for IronNotify operations. +/// Access via IronServicesClient.Notify +/// +public class NotifyApi +{ + private readonly IronServicesClient _client; + private readonly string _baseUrl; + + internal NotifyApi(IronServicesClient client, string baseUrl) + { + _client = client; + _baseUrl = baseUrl; + } + + private string Url(string endpoint) => IronServicesClient.BuildUrl(_baseUrl, endpoint); + + #region Notifications + + /// + /// Get notifications for the current user. + /// + public async Task> GetNotificationsAsync(string? appSlug = null, bool unreadOnly = false, int page = 1, int pageSize = 50, CancellationToken ct = default) + { + var query = new List { $"page={page}", $"pageSize={pageSize}" }; + if (unreadOnly) query.Add("isRead=false"); + if (!string.IsNullOrEmpty(appSlug)) query.Add($"type={appSlug}"); + var queryString = "?" + string.Join("&", query); + + var result = await _client.GetAsync(Url($"api/v1/notifications{queryString}"), ct); + return result?.Notifications ?? []; + } + + /// + /// Get unread notification count. + /// + public async Task GetUnreadCountAsync(CancellationToken ct = default) + { + var result = await _client.GetAsync(Url("api/v1/notifications/unread-count"), ct); + return result?.Count ?? result?.UnreadCount ?? 0; + } + + /// + /// Mark a notification as read. + /// + public async Task MarkAsReadAsync(Guid notificationId, CancellationToken ct = default) + { + await _client.PutAsync(Url($"api/v1/notifications/{notificationId}/read"), new { }, ct); + } + + /// + /// Mark all notifications as read. + /// + public async Task MarkAllAsReadAsync(CancellationToken ct = default) + { + await _client.PutAsync(Url("api/v1/notifications/read-all"), new { }, ct); + } + + /// + /// Acknowledge a notification (marks as read). + /// + public async Task AcknowledgeAsync(Guid notificationId, CancellationToken ct = default) + { + await MarkAsReadAsync(notificationId, ct); + } + + /// + /// Get a specific notification by ID. + /// + public async Task GetNotificationAsync(Guid notificationId, CancellationToken ct = default) + { + return await _client.GetAsync(Url($"api/v1/notifications/{notificationId}"), ct); + } + + /// + /// Resolve a notification. + /// + public async Task ResolveAsync(Guid notificationId, string? resolution = null, CancellationToken ct = default) + { + await _client.PostAsync(Url($"api/v1/notifications/{notificationId}/resolve"), new { resolution }, ct); + } + + /// + /// Get notification preferences. + /// + public async Task GetNotificationPreferencesAsync(CancellationToken ct = default) + { + return await _client.GetAsync(Url("api/v1/notifications/preferences"), ct); + } + + /// + /// Update notification preferences. + /// + public async Task UpdateNotificationPreferencesAsync(NotificationPreferences prefs, CancellationToken ct = default) + { + await _client.PutAsync(Url("api/v1/notifications/preferences"), prefs, ct); + } + + #endregion + + #region Apps + + /// + /// Get all apps for the tenant. + /// + public async Task> GetMyAppsAsync(CancellationToken ct = default) + { + return await _client.GetAsync>(Url("api/v1/apps"), ct) ?? []; + } + + /// + /// Get a specific app by ID. + /// + public async Task GetAppAsync(Guid appId, CancellationToken ct = default) + { + return await _client.GetAsync(Url($"api/v1/apps/{appId}"), ct); + } + + /// + /// Get an app by slug. + /// + public async Task GetAppBySlugAsync(string slug, CancellationToken ct = default) + { + return await _client.GetAsync(Url($"api/v1/apps/by-slug/{slug}"), ct); + } + + #endregion + + #region Members & On-Call + + /// + /// Get all team members. + /// + public async Task> GetTeamMembersAsync(CancellationToken ct = default) + { + return await _client.GetAsync>(Url("api/v1/members"), ct) ?? []; + } + + /// + /// Get current on-call member. + /// + public async Task GetOnCallMemberAsync(Guid? teamId = null, CancellationToken ct = default) + { + var query = teamId.HasValue ? $"?teamId={teamId}" : ""; + return await _client.GetAsync(Url($"api/v1/members/on-call{query}"), ct); + } + + /// + /// Get on-call status for the current user. + /// + public async Task GetOnCallStatusAsync(CancellationToken ct = default) + { + var member = await GetOnCallMemberAsync(ct: ct); + if (member == null) return new OnCallStatus { IsOnCall = false }; + + return new OnCallStatus + { + IsOnCall = true, + OnCallUserName = member.Name, + OnCallUserEmail = member.Email + }; + } + + /// + /// Set on-call status for a member. + /// + public async Task SetOnCallAsync(Guid memberId, bool isOnCall, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/members/{memberId}/on-call"), new { isOnCall }, ct); + } + + /// + /// Take on-call shift for current user. + /// + public async Task TakeOnCallAsync(CancellationToken ct = default) + { + // This would need the current user's member ID - placeholder for now + await Task.CompletedTask; + } + + /// + /// End on-call shift for current user. + /// + public async Task EndOnCallAsync(CancellationToken ct = default) + { + // This would need the current user's member ID - placeholder for now + await Task.CompletedTask; + } + + /// + /// Request coverage from another team member. + /// + public async Task RequestCoverageAsync(Guid memberId, CancellationToken ct = default) + { + // Placeholder - would send notification to member + await Task.CompletedTask; + } + + /// + /// Get today's on-call schedule. + /// + public async Task> GetScheduleAsync(CancellationToken ct = default) + { + // Placeholder - schedule endpoint not implemented in backend + return await Task.FromResult(new List()); + } + + #endregion + + #region App Muting + + /// + /// Mute notifications from an app. + /// + public async Task MuteAppAsync(string appSlug, int? minutes = null, CancellationToken ct = default) + { + // Placeholder - mute endpoint not implemented in backend + await Task.CompletedTask; + } + + /// + /// Unmute notifications from an app. + /// + public async Task UnmuteAppAsync(string appSlug, CancellationToken ct = default) + { + // Placeholder - unmute endpoint not implemented in backend + await Task.CompletedTask; + } + + #endregion + + #region Teams + + /// + /// Get all teams. + /// + public async Task> GetTeamsAsync(CancellationToken ct = default) + { + return await _client.GetAsync>(Url("api/v1/teams"), ct) ?? []; + } + + /// + /// Get a specific team. + /// + public async Task GetTeamAsync(Guid teamId, CancellationToken ct = default) + { + return await _client.GetAsync(Url($"api/v1/teams/{teamId}"), ct); + } + + /// + /// Get members of a specific team. + /// + public async Task> GetTeamMembersAsync(Guid teamId, CancellationToken ct = default) + { + return await _client.GetAsync>(Url($"api/v1/teams/{teamId}/members"), ct) ?? []; + } + + #endregion +} + +public class NotificationsResponse +{ + public List Notifications { get; set; } = []; + public int TotalCount { get; set; } + public int UnreadCount { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } +} + +public class TeamInfo +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public int MemberCount { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f91e4a0 --- /dev/null +++ b/README.md @@ -0,0 +1,237 @@ +# IronServices.Client + +Unified client for Iron Services APIs with session-based authentication. Access IronLicensing, IronNotify, and IronTelemetry from a single authenticated client. + +[![NuGet](https://img.shields.io/nuget/v/IronServices.Client.svg)](https://www.nuget.org/packages/IronServices.Client) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Installation + +```bash +dotnet add package IronServices.Client +``` + +## Quick Start + +```csharp +using IronServices.Client; + +// Create client +var client = new IronServicesClient(new IronServicesClientOptions +{ + LicensingUrl = "https://ironlicensing.com", + NotifyUrl = "https://ironnotify.com", + TelemetryUrl = "https://irontelemetry.com" +}); + +// Login +var loginResult = await client.LoginAsync("user@example.com", "password"); + +if (loginResult.Success) +{ + Console.WriteLine($"Welcome, {loginResult.DisplayName}!"); + + // Access all Iron Services APIs + var licenses = await client.Licensing.GetLicensesAsync(); + var events = await client.Notify.GetEventsAsync(); + var errors = await client.Telemetry.GetErrorsAsync(); +} +``` + +## Authentication + +### Login + +```csharp +var result = await client.LoginAsync("user@example.com", "password"); + +if (result.Success) +{ + Console.WriteLine($"User ID: {result.UserId}"); + Console.WriteLine($"Role: {result.Role}"); + Console.WriteLine($"Expires: {result.ExpiresAt}"); +} +else +{ + Console.WriteLine($"Login failed: {result.Error}"); +} +``` + +### Register + +```csharp +var result = await client.RegisterAsync( + email: "newuser@example.com", + password: "securepassword", + displayName: "John Doe" +); + +if (result.Success) +{ + Console.WriteLine($"Registered! API Key: {result.ApiKey}"); +} +``` + +### Email Verification + +```csharp +var result = await client.VerifyEmailAsync("user@example.com", "123456"); + +if (result.Success) +{ + Console.WriteLine("Email verified!"); +} +``` + +### Password Reset + +```csharp +// Request reset email +await client.RequestPasswordResetAsync("user@example.com"); + +// Reset with token from email +var result = await client.ResetPasswordAsync(token, "newpassword"); +``` + +### Session Management + +```csharp +// Check authentication status +if (client.IsAuthenticated) +{ + Console.WriteLine($"Session expires: {client.SessionExpiresAt}"); +} + +// Get current user profile +var profile = await client.GetProfileAsync(); + +// Logout +await client.LogoutAsync(); +``` + +## Token Storage + +Sessions are persisted automatically. Provide a custom storage for platform-specific secure storage: + +```csharp +public class SecureTokenStorage : ITokenStorage +{ + public async Task SaveTokenAsync(string token, DateTime? expiresAt) + { + await SecureStorage.SetAsync("session_token", token); + if (expiresAt.HasValue) + await SecureStorage.SetAsync("session_expires", expiresAt.Value.ToString("O")); + } + + public async Task<(string? token, DateTime? expiresAt)> GetTokenAsync() + { + var token = await SecureStorage.GetAsync("session_token"); + var expiresStr = await SecureStorage.GetAsync("session_expires"); + DateTime? expires = expiresStr != null ? DateTime.Parse(expiresStr) : null; + return (token, expires); + } + + public async Task ClearTokenAsync() + { + SecureStorage.Remove("session_token"); + SecureStorage.Remove("session_expires"); + } +} + +// Use custom storage +var client = new IronServicesClient(options, new SecureTokenStorage()); +``` + +## Service APIs + +### IronLicensing + +```csharp +// Get all licenses +var licenses = await client.Licensing.GetLicensesAsync(); + +// Get license details +var license = await client.Licensing.GetLicenseAsync(licenseId); + +// Create license +var newLicense = await client.Licensing.CreateLicenseAsync(new CreateLicenseRequest +{ + TierId = tierId, + CustomerEmail = "customer@example.com" +}); +``` + +### IronNotify + +```csharp +// Get events +var events = await client.Notify.GetEventsAsync(); + +// Get apps +var apps = await client.Notify.GetAppsAsync(); +``` + +### IronTelemetry + +```csharp +// Get errors +var errors = await client.Telemetry.GetErrorsAsync(); + +// Get projects +var projects = await client.Telemetry.GetProjectsAsync(); +``` + +## Configuration + +```csharp +var client = new IronServicesClient(new IronServicesClientOptions +{ + // Service URLs (defaults shown) + LicensingUrl = "https://ironlicensing.com", + NotifyUrl = "https://ironnotify.com", + TelemetryUrl = "https://irontelemetry.com" +}); +``` + +For local development: + +```csharp +var client = new IronServicesClient(new IronServicesClientOptions +{ + LicensingUrl = "https://localhost:5001", + NotifyUrl = "https://localhost:5003", + TelemetryUrl = "https://localhost:5005" +}); +``` + +## Generic API Methods + +Make authenticated requests to any endpoint: + +```csharp +// GET +var data = await client.GetAsync("https://ironlicensing.com/api/v1/custom"); + +// POST +var result = await client.PostAsync( + "https://ironlicensing.com/api/v1/custom", + new MyRequest { /* ... */ } +); + +// PUT +await client.PutAsync(url, data); + +// DELETE +await client.DeleteAsync(url); +``` + +## Links + +- [IronLicensing](https://www.ironlicensing.com) +- [IronNotify](https://www.ironnotify.com) +- [IronTelemetry](https://www.irontelemetry.com) +- [Documentation](https://www.ironlicensing.com/docs) + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/TelemetryApi.cs b/TelemetryApi.cs new file mode 100644 index 0000000..f89f234 --- /dev/null +++ b/TelemetryApi.cs @@ -0,0 +1,215 @@ +namespace IronServices.Client; + +/// +/// API client for IronTelemetry operations. +/// Access via IronServicesClient.Telemetry +/// +public class TelemetryApi +{ + private readonly IronServicesClient _client; + private readonly string _baseUrl; + + internal TelemetryApi(IronServicesClient client, string baseUrl) + { + _client = client; + _baseUrl = baseUrl; + } + + private string Url(string endpoint) => IronServicesClient.BuildUrl(_baseUrl, endpoint); + + #region Dashboard & Stats + + /// + /// Get issue statistics (serves as dashboard stats). + /// + public async Task GetDashboardStatsAsync(Guid? projectId = null, CancellationToken ct = default) + { + var query = projectId.HasValue ? $"?projectId={projectId}" : ""; + return await _client.GetAsync(Url($"api/v1/issues/stats{query}"), ct) + ?? new TelemetryDashboardStats(); + } + + #endregion + + #region Issues + + /// + /// Get issues with optional filtering. + /// + public async Task> GetIssuesAsync(Guid? projectId = null, string? status = null, int page = 1, int pageSize = 20, CancellationToken ct = default) + { + var query = new List { $"page={page}", $"pageSize={pageSize}" }; + if (projectId.HasValue) query.Add($"projectId={projectId}"); + if (!string.IsNullOrEmpty(status)) query.Add($"status={status}"); + var queryString = "?" + string.Join("&", query); + + var result = await _client.GetAsync(Url($"api/v1/issues{queryString}"), ct); + return result?.Items ?? []; + } + + /// + /// Get a specific issue by ID. + /// + public async Task GetIssueAsync(Guid issueId, CancellationToken ct = default) + { + return await _client.GetAsync(Url($"api/v1/issues/{issueId}"), ct); + } + + /// + /// Acknowledge an issue. + /// + public async Task AcknowledgeIssueAsync(Guid issueId, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/issues/{issueId}/acknowledge"), new { }, ct); + } + + /// + /// Resolve an issue. + /// + public async Task ResolveIssueAsync(Guid issueId, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/issues/{issueId}/resolve"), new { }, ct); + } + + /// + /// Ignore an issue. + /// + public async Task IgnoreIssueAsync(Guid issueId, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/issues/{issueId}/ignore"), new { }, ct); + } + + /// + /// Update an issue's status and other properties. + /// + public async Task UpdateIssueAsync(Guid issueId, UpdateIssueRequest request, CancellationToken ct = default) + { + return await _client.PutAsync(Url($"api/v1/issues/{issueId}"), request, ct); + } + + #endregion + + #region Signals + + /// + /// Get signals (telemetry notifications). + /// + public async Task> GetSignalsAsync(Guid? projectId = null, int page = 1, int pageSize = 50, CancellationToken ct = default) + { + var query = new List { $"page={page}", $"pageSize={pageSize}" }; + if (projectId.HasValue) query.Add($"projectId={projectId}"); + var queryString = "?" + string.Join("&", query); + + var result = await _client.GetAsync(Url($"api/v1/signals{queryString}"), ct); + return result?.Signals ?? []; + } + + /// + /// Get signal statistics. + /// + public async Task GetSignalStatsAsync(Guid? projectId = null, CancellationToken ct = default) + { + var query = projectId.HasValue ? $"?projectId={projectId}" : ""; + return await _client.GetAsync(Url($"api/v1/signals/stats{query}"), ct); + } + + /// + /// Dismiss a signal. + /// + public async Task DismissSignalAsync(Guid signalId, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/signals/{signalId}/dismiss"), new { }, ct); + } + + #endregion + + #region Projects + + /// + /// Get all projects. + /// + public async Task> GetProjectsAsync(CancellationToken ct = default) + { + return await _client.GetAsync>(Url("api/v1/projects"), ct) ?? []; + } + + /// + /// Get a specific project by ID. + /// + public async Task GetProjectAsync(Guid projectId, CancellationToken ct = default) + { + return await _client.GetAsync(Url($"api/v1/projects/{projectId}"), ct); + } + + /// + /// Create a new telemetry project. + /// + public async Task CreateProjectAsync(string name, string platform = "DotNet", CancellationToken ct = default) + { + return await _client.PostAsync(Url("api/v1/projects"), new { name, platform }, ct); + } + + /// + /// Update a project. + /// + public async Task UpdateProjectAsync(Guid projectId, UpdateProjectRequest request, CancellationToken ct = default) + { + return await _client.PutAsync(Url($"api/v1/projects/{projectId}"), request, ct); + } + + /// + /// Regenerate project DSN. + /// + public async Task RegenerateDsnAsync(Guid projectId, CancellationToken ct = default) + { + return await _client.PostAsync(Url($"api/v1/projects/{projectId}/regenerate-dsn"), new { }, ct); + } + + #endregion +} + +public class IssuesListResponse +{ + public List Items { get; set; } = []; + public int Total { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } +} + +public class SignalListResponse +{ + public List Signals { get; set; } = []; + public int Total { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public int TotalPages { get; set; } +} + +public class SignalStatsResponse +{ + public int TotalSignals { get; set; } + public int SignalsLast24Hours { get; set; } + public int SignalsLast7Days { get; set; } + public int SentToNotify { get; set; } + public int PendingSignals { get; set; } +} + +public class UpdateIssueRequest +{ + public string? Status { get; set; } + public string? Severity { get; set; } + public Guid? AssignedToUserId { get; set; } + public bool? NotifyOnRegression { get; set; } + public bool? NotifyOnNewOccurrence { get; set; } +} + +public class UpdateProjectRequest +{ + public string? Name { get; set; } + public string? Platform { get; set; } + public bool? IsActive { get; set; } + public bool? StackTraceGroupingEnabled { get; set; } + public bool? JourneyTrackingEnabled { get; set; } + public bool? SourceMapEnabled { get; set; } + public int? SampleRate { get; set; } +}