Initial commit: base slice (auth, roles, users, admin) scaffold

Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL +
Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4
with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's
conventions, scoped down to the current base feature set.
This commit is contained in:
Leonid Pershin
2026-07-24 05:40:34 +03:00
commit 8a3eebc48f
156 changed files with 9335 additions and 0 deletions
@@ -0,0 +1,21 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Behaviors;
/// <summary>Строит Result/Result&lt;T&gt; failure-ответ через reflection — общий хелпер для generic pipeline behaviors.</summary>
internal static class ResultFailureFactory
{
public static TResponse Create<TResponse>(Error error)
where TResponse : Result
{
if (typeof(TResponse) == typeof(Result))
return (TResponse)(object)Result.Failure(error);
var valueType = typeof(TResponse).GetGenericArguments()[0];
var method = typeof(Result)
.GetMethod(nameof(Result.Failure), 1, [typeof(Error)])!
.MakeGenericMethod(valueType);
return (TResponse)method.Invoke(null, [error])!;
}
}
@@ -0,0 +1,29 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Behaviors;
/// <summary>
/// Коммитит изменения после успешного выполнения команды. Применяется автоматически только
/// к запросам, реализующим <see cref="ICommand{TResponse}"/> — благодаря generic-ограничению
/// DI-контейнер не сможет сконструировать это поведение для запросов (IQuery).
/// </summary>
public sealed class UnitOfWorkBehavior<TRequest, TResponse>(IAppDbContext dbContext)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand<TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
var response = await next();
if (response is not Result { IsSuccess: false })
await dbContext.SaveChangesAsync(cancellationToken);
return response;
}
}
@@ -0,0 +1,38 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Behaviors;
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators
) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
where TResponse : Result
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
if (!validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.ToList();
if (failures.Count == 0)
return await next();
var error = Error.Validation(
"Validation.Failed",
string.Join("; ", failures.Select(f => f.ErrorMessage))
);
return ResultFailureFactory.Create<TResponse>(error);
}
}
@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Domain.Auth;
namespace TeleWave.Application.Common.Interfaces;
public interface IAppDbContext
{
DbSet<RefreshToken> RefreshTokens { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace TeleWave.Application.Common.Interfaces;
public interface ICurrentUser
{
Guid? UserId { get; }
string? UserName { get; }
bool IsAuthenticated { get; }
}
@@ -0,0 +1,62 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Interfaces;
public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsBlocked);
public sealed record UserSummaryDto(
Guid Id,
string UserName,
string Role,
bool IsBlocked,
DateTimeOffset CreatedAt
);
public interface IIdentityService
{
/// <summary>Создаёт пользователя и назначает роль по умолчанию (см. Infrastructure/Identity/RoleNames).</summary>
Task<Result<Guid>> CreateUserAsync(
string userName,
string password,
CancellationToken cancellationToken
);
Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(
string userName,
string password,
CancellationToken cancellationToken
);
Task<CurrentUserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken);
Task<Result> ChangePasswordAsync(
Guid userId,
string currentPassword,
string newPassword,
CancellationToken cancellationToken
);
Task<Result> ChangeUserNameAsync(
Guid userId,
string newUserName,
CancellationToken cancellationToken
);
/// <summary>Удаляет аккаунт (самоудаление или удаление админом).</summary>
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
Task<Result> BlockUserAsync(Guid userId, CancellationToken cancellationToken);
Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken);
Task<PagedList<UserSummaryDto>> ListUsersAsync(
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isBlocked,
CancellationToken cancellationToken
);
Task<UserSummaryDto?> GetUserAsync(Guid userId, CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace TeleWave.Application.Common.Interfaces;
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
public interface IJwtTokenService
{
(string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user);
}
@@ -0,0 +1,19 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Interfaces;
public sealed record IssuedRefreshToken(string RawToken, DateTimeOffset ExpiresAt);
public sealed record RotatedRefreshToken(Guid UserId, string RawToken, DateTimeOffset ExpiresAt);
public interface IRefreshTokenService
{
Task<IssuedRefreshToken> IssueAsync(Guid userId, CancellationToken cancellationToken);
Task<Result<RotatedRefreshToken>> RotateAsync(
string rawToken,
CancellationToken cancellationToken
);
Task RevokeAsync(string rawToken, CancellationToken cancellationToken);
}
@@ -0,0 +1,22 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Common.Interfaces;
public sealed record RoleDto(Guid Id, string Name, bool IsSystem);
public interface IRoleService
{
Task<Result<RoleDto>> CreateRoleAsync(string name, CancellationToken cancellationToken);
Task<Result<RoleDto>> UpdateRoleAsync(
Guid roleId,
string name,
CancellationToken cancellationToken
);
Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken);
Task<IReadOnlyList<RoleDto>> ListRolesAsync(CancellationToken cancellationToken);
Task<Result> ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken);
}
@@ -0,0 +1,34 @@
namespace TeleWave.Application.Common.Models;
public enum ErrorType
{
Failure,
Validation,
NotFound,
Conflict,
Unauthorized,
Forbidden,
}
public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure)
{
public static readonly Error None = new(string.Empty, string.Empty);
public static Error Validation(string code, string message) =>
new(code, message, ErrorType.Validation);
public static Error NotFound(string code, string message) =>
new(code, message, ErrorType.NotFound);
public static Error Conflict(string code, string message) =>
new(code, message, ErrorType.Conflict);
public static Error Unauthorized(string code, string message) =>
new(code, message, ErrorType.Unauthorized);
public static Error Forbidden(string code, string message) =>
new(code, message, ErrorType.Forbidden);
public static Error Failure(string code, string message) =>
new(code, message, ErrorType.Failure);
}
@@ -0,0 +1,3 @@
namespace TeleWave.Application.Common.Models;
public sealed record PagedList<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize);
@@ -0,0 +1,43 @@
namespace TeleWave.Application.Common.Models;
public class Result
{
public bool IsSuccess { get; }
public Error Error { get; }
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None)
throw new InvalidOperationException("Успешный результат не может содержать ошибку.");
if (!isSuccess && error == Error.None)
throw new InvalidOperationException("Неуспешный результат обязан содержать ошибку.");
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
public static Result<T> Success<T>(T value) => new(value, true, Error.None);
public static Result<T> Failure<T>(Error error) => new(default, false, error);
}
public class Result<T> : Result
{
private readonly T? _value;
internal Result(T? value, bool isSuccess, Error error)
: base(isSuccess, error) => _value = value;
public T Value =>
IsSuccess
? _value!
: throw new InvalidOperationException(
"Нельзя получить значение неуспешного результата."
);
public static implicit operator Result<T>(T value) => Success(value);
}