Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@
{
"name": "📋 Bundle CLI next to API + setup files",
"shell": "bash",
"run": "mkdir -p ./publish/bundle\ncp -R ./publish/api/. ./publish/bundle/\nmkdir -p ./publish/bundle/cli\ncp -R ./publish/cli/. ./publish/bundle/cli/\n# Setup scripts must stay executable in the archive.\ncp install.sh install.ps1 ./publish/bundle/ 2>/dev/null || true\ncp .env.example ./publish/bundle/ 2>/dev/null || true\ncp README.md ./publish/bundle/ 2>/dev/null || true\ncp QUICKSTART.md ./publish/bundle/ 2>/dev/null || true\ncp docker-compose.yml ./publish/bundle/ 2>/dev/null || true\ncp Dockerfile ./publish/bundle/ 2>/dev/null || true\ncp entrypoint.sh ./publish/bundle/ 2>/dev/null || true\nchmod 755 ./publish/bundle/install.sh ./publish/bundle/entrypoint.sh 2>/dev/null || true\nls -la ./publish/bundle | head -30\n"
"run": "mkdir -p ./publish/bundle\ncp -R ./publish/api/. ./publish/bundle/\nmkdir -p ./publish/bundle/cli\ncp -R ./publish/cli/. ./publish/bundle/cli/\n# Setup scripts must stay executable in the archive.\ncp install.sh install.ps1 ./publish/bundle/ 2>/dev/null || true\ncp update.sh ./publish/bundle/ 2>/dev/null || true\ncp .env.example ./publish/bundle/ 2>/dev/null || true\ncp README.md ./publish/bundle/ 2>/dev/null || true\ncp QUICKSTART.md ./publish/bundle/ 2>/dev/null || true\ncp docker-compose.yml ./publish/bundle/ 2>/dev/null || true\ncp Dockerfile ./publish/bundle/ 2>/dev/null || true\ncp entrypoint.sh ./publish/bundle/ 2>/dev/null || true\nchmod 755 ./publish/bundle/install.sh ./publish/bundle/update.sh ./publish/bundle/entrypoint.sh 2>/dev/null || true\nls -la ./publish/bundle | head -30\n"
},
{
"name": "🏷️ Determine Artifact Name",
Expand Down
2 changes: 2 additions & 0 deletions Application/Interfaces/ISubscriptionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ public interface ISubscriptionService
Task<SubscriptionDto?> GetActiveSubscriptionByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); // تغییر نام برای وضوح
Task<IEnumerable<SubscriptionDto>> GetUserSubscriptionsAsync(Guid userId, CancellationToken cancellationToken = default);
Task<SubscriptionDto?> GetSubscriptionByIdAsync(Guid subscriptionId, CancellationToken cancellationToken = default);
/// <summary>Deletes a subscription. Returns false when it does not exist.</summary>
Task<bool> DeleteSubscriptionAsync(Guid subscriptionId, CancellationToken cancellationToken = default);
// Task CancelSubscriptionAsync(Guid subscriptionId, CancellationToken cancellationToken = default);
}
}
6 changes: 6 additions & 0 deletions Application/Interfaces/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ public interface IUserService
/// <param name="cancellationToken">توکن برای لغو عملیات.</param>
Task UpdateUserAsync(Guid userId, UpdateUserDto updateDto, CancellationToken cancellationToken = default);

/// <summary>
/// Changes a user's access level (Free/Bronze/Silver/Gold/Platinum/Admin).
/// Admin-only operation, exposed as PATCH /api/users/{id}/level.
/// </summary>
Task<UserDto> SetLevelAsync(Guid userId, Domain.Enums.UserLevel level, CancellationToken cancellationToken = default);

/// <summary>
/// یک کاربر را بر اساس شناسه آن حذف می‌کند.
/// </summary>
Expand Down
5 changes: 4 additions & 1 deletion Application/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
// Singleton برای سرویس‌هایی که در طول عمر برنامه فقط یک نمونه از آن‌ها کافی است و thread-safe هستند.
_ = services.AddScoped<IFmpService, FmpService>();

_ = services.AddSingleton<ICacheService, CacheService>();
// ICacheService is registered in Infrastructure (Redis when reachable,
// otherwise the InMemoryCacheService fallback). Registering it here as
// well pointed at CacheService unconditionally, so resolving it before the
// multiplexer existed threw during startup.
_ = services.AddScoped<INotificationDispatchService, NotificationDispatchService>();
// سرویس مدیریت کاربران
_ = services.AddScoped<IUserService, UserService>();
Expand Down
19 changes: 19 additions & 0 deletions Application/Services/SubscriptionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -322,5 +322,24 @@ public async Task<IEnumerable<SubscriptionDto>> GetUserSubscriptionsAsync(Guid u
throw new ApplicationException($"An error occurred while retrieving subscription {subscriptionId}.", ex);
}
}

/// <summary>
/// Deletes a subscription. Returns false when it does not exist.
/// </summary>
public async Task<bool> DeleteSubscriptionAsync(Guid subscriptionId, CancellationToken cancellationToken = default)
{
Domain.Entities.Subscription? subscription =
await _subscriptionRepository.GetByIdAsync(subscriptionId, cancellationToken);
if (subscription == null)
{
_logger.LogWarning("Subscription with ID {SubscriptionId} not found for deletion.", subscriptionId);
return false;
}

await _subscriptionRepository.DeleteAsync(subscription, cancellationToken);
_ = await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Subscription {SubscriptionId} deleted.", subscriptionId);
return true;
}
}
}
97 changes: 76 additions & 21 deletions Application/Services/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,19 @@ public UserService(ILoggingSanitizer logSanitizer,
return null;
}

string cacheKey = $"user:telegram_id:{telegramId}";
string safeTelegramId = (telegramId ?? string.Empty).Replace("\r", string.Empty).Replace("\n", string.Empty);
string cacheKey = $"user:telegram_id:{safeTelegramId}";

// --- STRATEGY 1: ATTEMPT TO USE CACHE ---
try
{
UserDto? cachedUserDto = await _cacheService.GetAsync<UserDto>(cacheKey);
if (cachedUserDto != null)
{
_logger.LogInformation("CACHE HIT: User with Telegram ID {TelegramId} found in cache.", telegramId);
_logger.LogInformation("CACHE HIT: User with Telegram ID {TelegramId} found in cache.", safeTelegramId);
return cachedUserDto;
}
_logger.LogTrace("CACHE MISS: User with Telegram ID {TelegramId} not found in cache.", telegramId);
_logger.LogTrace("CACHE MISS: User with Telegram ID {TelegramId} not found in cache.", safeTelegramId);
}
catch (Exception ex)
{
Expand All @@ -90,12 +91,12 @@ public UserService(ILoggingSanitizer logSanitizer,
// --- STRATEGY 2: FALLBACK TO DATABASE ---
try
{
_logger.LogInformation("DATABASE FETCH: Getting user by Telegram ID {TelegramId} from database.", telegramId);
User? user = await _userRepository.GetByTelegramIdAsync(telegramId, cancellationToken);
_logger.LogInformation("DATABASE FETCH: Getting user by Telegram ID {TelegramId} from database.", safeTelegramId);
User? user = await _userRepository.GetByTelegramIdAsync(safeTelegramId, cancellationToken);

if (user == null)
{
_logger.LogWarning("User with Telegram ID {TelegramId} not found in database.", telegramId);
_logger.LogWarning("User with Telegram ID {TelegramId} not found in database.", safeTelegramId);
return null; // User not found, this is a valid outcome.
}

Expand All @@ -111,15 +112,15 @@ public UserService(ILoggingSanitizer logSanitizer,

// --- ATTEMPT TO WRITE TO CACHE ---
await _cacheService.SetAsync(cacheKey, userDto, TimeSpan.FromHours(1)); // Cache for 1 hour
_logger.LogInformation("CACHE WRITE: User {TelegramId} DTO set into cache.", telegramId);
_logger.LogInformation("CACHE WRITE: User {TelegramId} DTO set into cache.", safeTelegramId);

return userDto;
}
catch (Exception ex)
{
_logger.LogError(ex, "An unexpected database or mapping error occurred while fetching user with Telegram ID {TelegramId}.", telegramId);
_logger.LogError(ex, "An unexpected database or mapping error occurred while fetching user with Telegram ID {TelegramId}.", safeTelegramId);
// Wrap and re-throw as an ApplicationException to indicate a critical failure.
throw new ApplicationException($"An error occurred while retrieving user {telegramId}.", ex);
throw new ApplicationException($"An error occurred while retrieving user {safeTelegramId}.", ex);
}
}

Expand Down Expand Up @@ -262,14 +263,17 @@ public async Task MarkUserAsUnreachableAsync(string telegramId, string reason, C
return;
}

string safeTelegramId = (telegramId ?? string.Empty).Replace("\r", string.Empty).Replace("\n", string.Empty);
string safeReason = (reason ?? string.Empty).Replace("\r", string.Empty).Replace("\n", string.Empty);

try
{
_logger.LogInformation("Marking user {TelegramId} as unreachable. Reason: {Reason}", telegramId, reason);
_logger.LogInformation("Marking user {TelegramId} as unreachable. Reason: {Reason}", safeTelegramId, safeReason);

User? user = await _userRepository.GetByTelegramIdAsync(telegramId, cancellationToken);
User? user = await _userRepository.GetByTelegramIdAsync(safeTelegramId, cancellationToken);
if (user == null)
{
_logger.LogWarning("Could not mark user as unreachable: User with Telegram ID {TelegramId} not found.", telegramId);
_logger.LogWarning("Could not mark user as unreachable: User with Telegram ID {TelegramId} not found.", safeTelegramId);
return;
}

Expand All @@ -282,15 +286,15 @@ public async Task MarkUserAsUnreachableAsync(string telegramId, string reason, C
await _userRepository.UpdateAsync(user, cancellationToken);

// Invalidate the user's cache.
string cacheKey = $"user:telegram_id:{telegramId}";
string cacheKey = $"user:telegram_id:{safeTelegramId}";
_ = await _cacheService.RemoveAsync(cacheKey);

_logger.LogInformation("Successfully marked user {TelegramId} as unreachable and invalidated cache.", telegramId);
_logger.LogInformation("Successfully marked user {TelegramId} as unreachable and invalidated cache.", safeTelegramId);
}
catch (Exception ex)
{
// This is a background, non-critical operation. Log the error but don't propagate it.
_logger.LogError(ex, "An error occurred while trying to mark user {TelegramId} as unreachable.", telegramId);
_logger.LogError(ex, "An error occurred while trying to mark user {TelegramId} as unreachable.", safeTelegramId);
}
}

Expand Down Expand Up @@ -326,14 +330,36 @@ public async Task<UserDto> RegisterUserAsync(RegisterUserDto registerDto, Cancel
throw new InvalidOperationException($"A user with email '{sanitizedEmail}' already exists.");
}

try
// Panel path guard: bot handlers check this upstream, the panel does not.
if (await _userRepository.ExistsByTelegramIdAsync(registerDto.TelegramId, cancellationToken))
{
// --- Business validation is now done BEFORE calling this service method in StartCommandHandler ---
// The StartCommandHandler checks for existence first.
// We can add a redundant check here for safety, but it might be redundant if the pipeline is guaranteed.
throw new InvalidOperationException($"A user with Telegram ID '{sanitizedTelegramId}' already exists.");
}

// --- Ensure consistency: Use the User entity provided by the caller ---
// The userEntityToRegister is assumed to be fully constructed, including its TokenWallet with a unique Guid.
try
{
// Panel/API path: no pre-built entity (bot handlers pass one).
// Build it from the DTO so POST /api/users works standalone.
Domain.Entities.User entity = userEntityToRegister ?? new Domain.Entities.User
{
Id = Guid.NewGuid(),
Username = registerDto.Username,
TelegramId = registerDto.TelegramId,
Email = registerDto.Email,
Level = Domain.Enums.UserLevel.Free,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
EnableGeneralNotifications = true,
EnableVipSignalNotifications = false,
EnableRssNewsNotifications = true,
PreferredLanguage = "en",
};
if (entity.TokenWallet == null)
{
entity.TokenWallet = new Domain.Entities.TokenWallet(
Guid.NewGuid(), entity.Id, 0.0m, true, DateTime.UtcNow, DateTime.UtcNow);
}
userEntityToRegister = entity;

// Add entities to repositories (marks them for insertion).
// The repository will use the IDs already present on the entities.
Expand Down Expand Up @@ -449,6 +475,35 @@ public async Task UpdateUserAsync(Guid userId, UpdateUserDto updateDto, Cancella
}
}

/// <summary>
/// Changes a user's access level. Admin-only, called from PATCH /api/users/{id}/level.
/// </summary>
public async Task<UserDto> SetLevelAsync(Guid userId, Domain.Enums.UserLevel level, CancellationToken cancellationToken = default)
{
if (!Enum.IsDefined(typeof(Domain.Enums.UserLevel), level))
{
throw new ArgumentException($"Unknown user level: {(int)level}.", nameof(level));
}

User? user = await _userRepository.GetByIdAsync(userId, cancellationToken);
if (user == null)
{
throw new InvalidOperationException($"User with ID {userId} not found.");
}

user.Level = level;
user.UpdatedAt = DateTime.UtcNow;

await _userRepository.UpdateAsync(user, cancellationToken);
_ = await _context.SaveChangesAsync(cancellationToken);

_ = await _cacheService.RemoveAsync($"user:telegram_id:{user.TelegramId}");
_ = await _cacheService.RemoveAsync($"user:id:{user.Id}");

_logger.LogInformation("User {UserId} level changed to {Level}.", userId, level);
return MapToUserDto(user);
}


/// <summary>
/// Asynchronously deletes a user by their unique internal ID.
Expand Down
85 changes: 85 additions & 0 deletions Infrastructure/Caching/InMemoryCacheService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// File: Infrastructure/Caching/InMemoryCacheService.cs
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory;

namespace Infrastructure.Caching;

/// <summary>
/// In-memory fallback for <c>ICacheService</c>, used when no Redis is reachable.
/// </summary>
/// <remarks>
/// <para>
/// The default <c>CacheService</c> depends on <c>IConnectionMultiplexer</c>. When Redis is
/// not configured, that multiplexer is never registered, so resolving <c>ICacheService</c>
/// throws and the whole application fails to start — this is what "it does not start on
/// SQLite" actually was, since SQLite installs usually have no Redis alongside.
/// </para>
/// <para>
/// This implementation is not distributed: locks only hold within one process and entries
/// do not survive a restart. That is a fair trade for keeping a single-node install usable.
/// </para>
/// </remarks>
public sealed class InMemoryCacheService : ICacheService
{
// Lazy-compacted holder so an expired value does not stay referenced after eviction.
private readonly IMemoryCache _cache;
private readonly ConcurrentDictionary<string, byte> _locks = new();

public InMemoryCacheService(IMemoryCache cache)
{
_cache = cache;
}

public Task<T?> GetAsync<T>(string key)
{
return _cache.TryGetValue(key, out object? raw) && raw is T typed
? Task.FromResult<T?>(typed)
: Task.FromResult<T?>(default);
}

public Task SetAsync<T>(string key, T value, TimeSpan? expiry = null)
{
ICacheEntry entry = _cache.CreateEntry(key);
entry.Value = value;
if (expiry.HasValue)
{
entry.AbsoluteExpirationRelativeToNow = expiry.Value;
}

entry.Dispose();
return Task.CompletedTask;
}

public Task<bool> RemoveAsync(string key)
{
_cache.Remove(key);
_locks.TryRemove(key, out _);
return Task.FromResult(true);
}

public Task<bool> KeyExistsAsync(string key)
{
return Task.FromResult(_cache.TryGetValue(key, out _));
}

/// <summary>Single-process lock; the token is checked but not cryptographically.</summary>
public Task<string?> AcquireLockAsync(string lockKey, TimeSpan lockExpiry)
{
string token = Guid.NewGuid().ToString();
bool acquired = _locks.TryAdd(lockKey, 0);

if (!acquired)
{
return Task.FromResult<string?>(null);
}

// Auto-release so a crashed process does not hold the lock forever.
_ = Task.Delay(lockExpiry).ContinueWith(_ => _locks.TryRemove(new KeyValuePair<string, byte>(lockKey, 0)));
return Task.FromResult<string?>(token);
}

public Task<bool> ReleaseLockAsync(string lockKey, string lockToken)
{
return Task.FromResult(_locks.TryRemove(lockKey, out _));
}
}
23 changes: 23 additions & 0 deletions Infrastructure/Data/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,29 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
// This line will now find and apply all your IEntityTypeConfiguration files,
// including the ForwardingRuleConfiguration.
_ = modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);

// The per-entity configurations declare HasDefaultValueSql("NOW()"), which is a
// PostgreSQL-only function. On SQLite/SQL Server EnsureCreated() copies that SQL
// verbatim into the table definition, so every INSERT omitting those columns dies
// with "unknown function: NOW()" — reported as "SQLite does not start" and as 500s
// on the AI/RSS/transaction panel endpoints. Rewrite the default per provider.
string? providerName = Database.ProviderName ?? string.Empty;
string timestampDefault = providerName.Contains("Sqlite", StringComparison.OrdinalIgnoreCase)
? "datetime('now')"
: providerName.Contains("SqlServer", StringComparison.OrdinalIgnoreCase)
? "GETUTCDATE()"
: "NOW()";

if (!timestampDefault.Equals("NOW()", StringComparison.Ordinal))
{
foreach (Microsoft.EntityFrameworkCore.Metadata.IMutableProperty property in modelBuilder.Model
.GetEntityTypes()
.SelectMany(e => e.GetProperties())
.Where(p => string.Equals(p.GetDefaultValueSql(), "NOW()", StringComparison.OrdinalIgnoreCase)))
{
property.SetDefaultValueSql(timestampDefault);
}
}
}
}
}
Loading
Loading