commit d64072a3347f53ffcfda370bbd600f5270cd9fac Author: David Friedel Date: Thu Dec 25 09:09:12 2025 +0000 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 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; } +}