feat: extract channel management to IChannelService and implement CRUD operations

This commit is contained in:
HueByte
2026-02-21 17:16:41 +01:00
parent 75491d65e2
commit 0d959e317e
13 changed files with 402 additions and 203 deletions
@@ -8,7 +8,6 @@ using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
@@ -18,6 +17,7 @@ namespace EchoHub.Server.Controllers;
[EnableRateLimiting("general")]
public class ChannelsController : ControllerBase
{
private readonly IChannelService _channelService;
private readonly EchoHubDbContext _db;
private readonly FileStorageService _fileStorage;
private readonly ImageToAsciiService _asciiService;
@@ -26,6 +26,7 @@ public class ChannelsController : ControllerBase
private readonly IMessageEncryptionService _encryption;
public ChannelsController(
IChannelService channelService,
EchoHubDbContext db,
FileStorageService fileStorage,
ImageToAsciiService asciiService,
@@ -33,6 +34,7 @@ public class ChannelsController : ControllerBase
IChatService chatService,
IMessageEncryptionService encryption)
{
_channelService = channelService;
_db = db;
_fileStorage = fileStorage;
_asciiService = asciiService;
@@ -48,87 +50,29 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100);
// Ensure #general always exists
if (!await _db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
{
_db.Channels.Add(new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
});
await _db.SaveChangesAsync();
}
// Public channels + private channels the user has joined
var query = _db.Channels.Where(c =>
c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
var total = await query.CountAsync();
var channels = await query
.OrderBy(c => c.Name)
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id,
c.Name,
c.Topic,
c.IsPublic,
c.Messages.Count,
c.CreatedAt))
.ToListAsync();
return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit);
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new ErrorResponse("Channel name is required."));
var channelName = request.Name.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
if (await _db.Channels.AnyAsync(c => c.Name == channelName))
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
Topic = request.Topic?.Trim(),
IsPublic = request.IsPublic,
CreatedByUserId = Guid.Parse(userIdClaim),
};
var result = await _channelService.CreateChannelAsync(
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic);
if (!result.IsSuccess)
return MapChannelError(result);
_db.Channels.Add(channel);
if (result.Channel!.IsPublic)
await _chatService.BroadcastChannelUpdatedAsync(result.Channel);
// Creator automatically becomes a member
_db.ChannelMemberships.Add(new ChannelMembership
{
UserId = Guid.Parse(userIdClaim),
ChannelId = channel.Id,
});
await _db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
if (channel.IsPublic)
await _chatService.BroadcastChannelUpdatedAsync(dto);
return Created($"/api/channels/{channelName}", dto);
return Created($"/api/channels/{result.Channel.Name}", result.Channel);
}
[HttpPut("{channel}/topic")]
@@ -138,26 +82,13 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim();
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
var result = await _channelService.UpdateTopicAsync(
Guid.Parse(userIdClaim), channel, request.Topic);
if (!result.IsSuccess)
return MapChannelError(result);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
dbChannel.Topic = request.Topic?.Trim();
await _db.SaveChangesAsync();
var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
await _chatService.BroadcastChannelUpdatedAsync(dto, channelName);
return Ok(dto);
await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim());
return Ok(result.Channel);
}
[HttpDelete("{channel}")]
@@ -167,23 +98,9 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim();
if (channelName == HubConstants.DefaultChannel)
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
var userId = Guid.Parse(userIdClaim);
var caller = await _db.Users.FindAsync(userId);
if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin))
return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel."));
_db.Channels.Remove(dbChannel);
await _db.SaveChangesAsync();
var result = await _channelService.DeleteChannelAsync(Guid.Parse(userIdClaim), channel);
if (!result.IsSuccess)
return MapChannelError(result);
return NoContent();
}
@@ -203,8 +120,8 @@ public class ChannelsController : ControllerBase
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
var channelDto = await _channelService.GetChannelByNameAsync(channelName);
if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
@@ -250,7 +167,7 @@ public class ChannelsController : ControllerBase
AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName,
SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id,
ChannelId = channelDto.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
@@ -290,8 +207,8 @@ public class ChannelsController : ControllerBase
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
var channelDto = await _channelService.GetChannelByNameAsync(channelName);
if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (string.IsNullOrWhiteSpace(request.Url))
@@ -370,7 +287,7 @@ public class ChannelsController : ControllerBase
AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName,
SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id,
ChannelId = channelDto.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
@@ -394,4 +311,14 @@ public class ChannelsController : ControllerBase
return Ok(messageDto);
}
private IActionResult MapChannelError(ChannelOperationResult result) => result.Error switch
{
ChannelError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
ChannelError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
ChannelError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
ChannelError.Forbidden => StatusCode(403, new ErrorResponse(result.ErrorMessage!)),
ChannelError.Protected => BadRequest(new ErrorResponse(result.ErrorMessage!)),
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
};
}
+1
View File
@@ -115,6 +115,7 @@ while (true)
// ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IChannelService, ChannelService>();
builder.Services.AddSingleton<IChatService, ChatService>();
// ── IRC Gateway (optional) ──────────────────────────────────────────
@@ -0,0 +1,247 @@
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace EchoHub.Server.Services;
public class ChannelService : IChannelService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly PresenceTracker _presenceTracker;
private readonly ILogger<ChannelService> _logger;
public ChannelService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
ILogger<ChannelService> logger)
{
_scopeFactory = scopeFactory;
_presenceTracker = presenceTracker;
_logger = logger;
}
public async Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
await EnsureDefaultChannelAsync(db);
var query = db.Channels.Where(c =>
c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
var total = await query.CountAsync();
var channels = await query
.OrderBy(c => c.Name)
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt))
.ToListAsync();
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
}
public async Task<ChannelOperationResult> CreateChannelAsync(
Guid creatorUserId, string name, string? topic, bool isPublic)
{
if (string.IsNullOrWhiteSpace(name))
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required.");
var channelName = name.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
if (await db.Channels.AnyAsync(c => c.Name == channelName))
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
Topic = topic?.Trim(),
IsPublic = isPublic,
CreatedByUserId = creatorUserId,
};
db.Channels.Add(channel);
// Creator automatically becomes a member
db.ChannelMemberships.Add(new ChannelMembership
{
UserId = creatorUserId,
ChannelId = channel.Id,
});
await db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
return ChannelOperationResult.Success(dto);
}
public async Task<ChannelOperationResult> UpdateTopicAsync(
Guid callerUserId, string channelName, string? topic)
{
channelName = channelName.ToLowerInvariant().Trim();
if (topic is not null && topic.Length > ValidationConstants.MaxChannelTopicLength)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
$"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
if (dbChannel.CreatedByUserId != callerUserId)
return ChannelOperationResult.Fail(ChannelError.Forbidden, "Only the channel creator can update the topic.");
dbChannel.Topic = topic?.Trim();
await db.SaveChangesAsync();
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
return ChannelOperationResult.Success(dto);
}
public async Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
if (channelName == HubConstants.DefaultChannel)
return ChannelOperationResult.Fail(ChannelError.Protected,
$"The '{HubConstants.DefaultChannel}' channel cannot be deleted.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
var caller = await db.Users.FindAsync(callerUserId);
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
return ChannelOperationResult.Fail(ChannelError.Forbidden,
"Only the channel creator or an admin can delete the channel.");
db.Channels.Remove(dbChannel);
await db.SaveChangesAsync();
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt);
return ChannelOperationResult.Success(dto);
}
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
return channels.Select(c => new ChannelListItem(
c.Name, c.Topic,
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
}
public async Task<ChannelDto?> GetChannelByNameAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
if (c is null) return null;
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt);
}
public async Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
// Auto-recreate #general if it was somehow removed
if (channelName == HubConstants.DefaultChannel)
{
channel = new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
}
else
{
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.");
}
}
var hasMembership = await db.ChannelMemberships
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
if (!hasMembership)
{
db.ChannelMemberships.Add(new ChannelMembership
{
UserId = userId,
ChannelId = channel.Id,
});
await db.SaveChangesAsync();
}
return (true, null);
}
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
{
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
{
db.Channels.Add(new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
});
await db.SaveChangesAsync();
}
}
}
+8 -68
View File
@@ -17,6 +17,7 @@ public class ChatService : IChatService
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
private readonly LinkEmbedService _embedService;
private readonly IMessageEncryptionService _encryption;
private readonly IChannelService _channelService;
private readonly ILogger<ChatService> _logger;
public ChatService(
@@ -25,6 +26,7 @@ public class ChatService : IChatService
IEnumerable<IChatBroadcaster> broadcasters,
LinkEmbedService embedService,
IMessageEncryptionService encryption,
IChannelService channelService,
ILogger<ChatService> logger)
{
_scopeFactory = scopeFactory;
@@ -32,6 +34,7 @@ public class ChatService : IChatService
_broadcasters = broadcasters;
_embedService = embedService;
_encryption = encryption;
_channelService = channelService;
_logger = logger;
}
@@ -95,47 +98,10 @@ public class ChatService : IChatService
{
channelName = channelName.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
// Auto-recreate #general if it was somehow removed
if (channelName == HubConstants.DefaultChannel)
{
channel = new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
}
else
{
return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list.");
}
}
// Persist membership so the channel shows in the user's channel list
var hasMembership = await db.ChannelMemberships
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
if (!hasMembership)
{
db.ChannelMemberships.Add(new ChannelMembership
{
UserId = userId,
ChannelId = channel.Id,
});
await db.SaveChangesAsync();
}
// Delegate channel validation + membership to ChannelService
var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName);
if (!success)
return ([], error);
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
@@ -145,7 +111,7 @@ public class ChatService : IChatService
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
}
var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount);
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
return (history, null);
}
@@ -354,32 +320,6 @@ public class ChatService : IChatService
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
}
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
return channels.Select(c => new ChannelListItem(
c.Name,
c.Topic,
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
}
public Task<List<string>> GetChannelsForUserAsync(string username)
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));