diff --git a/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs b/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs index e00af4e26a..93129df818 100644 --- a/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs +++ b/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs @@ -1,5 +1,7 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Persistence; +using Microsoft.Extensions.Localization; namespace FSH.Framework.Web.Validation; @@ -10,9 +12,9 @@ namespace FSH.Framework.Web.Validation; /// /// public class MyQueryValidator : AbstractValidator<MyQuery> /// { -/// public MyQueryValidator() +/// public MyQueryValidator(IStringLocalizer<SharedResources> localizer) /// { -/// Include(new PagedQueryValidator<MyQuery>()); +/// Include(new PagedQueryValidator<MyQuery>(localizer)); /// // Add additional rules... /// } /// } @@ -20,21 +22,21 @@ namespace FSH.Framework.Web.Validation; public sealed class PagedQueryValidator : AbstractValidator where T : IPagedQuery { - public PagedQueryValidator() + public PagedQueryValidator(IStringLocalizer localizer) { RuleFor(q => q.PageNumber) .GreaterThan(0) .When(q => q.PageNumber.HasValue) - .WithMessage("Page number must be greater than 0."); + .WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(q => q.PageSize) .InclusiveBetween(1, 100) .When(q => q.PageSize.HasValue) - .WithMessage("Page size must be between 1 and 100."); + .WithMessage(_ => localizer["Validation.PageSizeRange"]); RuleFor(q => q.Sort) .MaximumLength(200) .When(q => !string.IsNullOrEmpty(q.Sort)) - .WithMessage("Sort expression must not exceed 200 characters."); + .WithMessage(_ => localizer["Validation.SortMaxLength"]); } } \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0d38b28190..7674befa8f 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -143,5 +143,12 @@ AccessViolation). Transitive pinning is enabled, so this entry alone bumps it. Remove once the SignalR backplane package depends on a patched version itself. --> + + \ No newline at end of file diff --git a/src/FSH.Starter.slnx b/src/FSH.Starter.slnx index 998d538836..c51beb777a 100644 --- a/src/FSH.Starter.slnx +++ b/src/FSH.Starter.slnx @@ -80,6 +80,7 @@ + diff --git a/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs b/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs index 9dc0554d0f..d03ac8301b 100644 --- a/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs +++ b/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Exceptions; using FSH.Modules.Auditing.Contracts; using System.Diagnostics; @@ -56,13 +57,21 @@ public static Builder ForException(Exception ex, ExceptionArea area = ExceptionA eventType: AuditEventType.Exception, severity: severity ?? DefaultSeverity(ex), payload: new ExceptionEventPayload(area, - ex.GetType().FullName ?? "Exception", + RealExceptionType(ex).FullName ?? "Exception", ex.Message ?? string.Empty, StackTop(ex, maxFrames: 20), ToDict(ex.Data), routeOrLocation)); } + // Localization wrappers (LocalizedKeyNotFoundException, LocalizedUnauthorizedAccessException) exist + // only to translate the response body; for audit type identity and exceptionType filtering they must + // present as their BCL base so queries stay stable. CustomException-derived types keep their own identity. + private static Type RealExceptionType(Exception ex) => + ex is ILocalizableMessage and not CustomException && ex.GetType().BaseType is { } baseType + ? baseType + : ex.GetType(); + private static AuditSeverity DefaultSeverity(Exception ex) { if (ex is OperationCanceledException) diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs index ec9e845dee..122edc7123 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs @@ -1,5 +1,7 @@ +using FSH.Framework.Core.Exceptions; using FSH.Modules.Auditing.Contracts; using FSH.Modules.Auditing.Contracts.Dtos; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Auditing.Contracts.v1.GetAuditById; using FSH.Modules.Auditing.Persistence; using Mediator; @@ -31,9 +33,15 @@ public async ValueTask Handle(GetAuditByIdQuery query, Cancellat if (record is null) { - // KeyNotFoundException maps to 404 globally. Kept (not framework NotFoundException) - // because audit exception-type fixtures and severity classification key off this type. - throw new KeyNotFoundException($"Audit record {query.Id} not found."); + // LocalizedKeyNotFoundException maps to 404 globally and still keys off KeyNotFoundException + // (its base) for audit exception-type fixtures and severity classification, while the body + // localizes via MessageKey under the request culture. + throw new LocalizedKeyNotFoundException($"Audit record {query.Id} not found.") + { + MessageKey = "Auditing.AuditRecordNotFound", + MessageArgs = [query.Id], + ResourceSource = typeof(AuditingResources), + }; } JsonElement payload; diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs index 90b954f77a..142cb1bc47 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Auditing.Contracts.Authorization; using FSH.Modules.Auditing.Contracts.Dtos; using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Auditing.Persistence; using FSH.Modules.Identity.Contracts.Services; using Mediator; @@ -13,7 +14,8 @@ namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary; public sealed class GetAuditSummaryQueryHandler : IQueryHandler { - public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(90); + public const int MaxWindowDays = 90; + public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(MaxWindowDays); public static readonly TimeSpan DefaultWindow = TimeSpan.FromDays(7); private readonly AuditDbContext _dbContext; @@ -104,7 +106,11 @@ requested is not null .ConfigureAwait(false); if (!allowed) { - throw new ForbiddenException("Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant."); + throw new ForbiddenException("Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant.") + { + MessageKey = "Error.Auditing.CrossTenantSummaryForbidden", + ResourceSource = typeof(AuditingResources), + }; } return _dbContext.AuditRecords diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs index 76a39601a8..c1f0d82888 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs @@ -1,21 +1,26 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary; public sealed class GetAuditSummaryQueryValidator : AbstractValidator { - public GetAuditSummaryQueryValidator() + public GetAuditSummaryQueryValidator(IStringLocalizer localizer) { RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || (q.ToUtc.Value - q.FromUtc.Value) <= GetAuditSummaryQueryHandler.MaxWindow) - .WithMessage($"Audit summary window cannot exceed {GetAuditSummaryQueryHandler.MaxWindow.TotalDays:0} days."); + // MaxWindowDays, not MaxWindow.TotalDays: the localizer formats arguments with + // string.Format under the current culture, and a double in a localized message is + // culture-sensitive by construction. An int cannot render a decimal separator. + .WithMessage(_ => localizer["Validation.SummaryWindowExceeded", GetAuditSummaryQueryHandler.MaxWindowDays]); } } diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs index f14e46bad8..865d33e975 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs @@ -6,6 +6,7 @@ using FSH.Modules.Auditing.Contracts.Authorization; using FSH.Modules.Auditing.Contracts.Dtos; using FSH.Modules.Auditing.Contracts.v1.GetAudits; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Auditing.Persistence; using FSH.Modules.Identity.Contracts.Services; using Mediator; @@ -21,7 +22,10 @@ public sealed class GetAuditsQueryHandler : IQueryHandler - public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(90); + public const int MaxWindowDays = 90; + + /// + public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(MaxWindowDays); /// /// Default lookback when the caller does not supply a from/to. Keeps the @@ -157,7 +161,11 @@ requested is not null .ConfigureAwait(false); if (!allowed) { - throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant."); + throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant.") + { + MessageKey = "Error.Auditing.CrossTenantAccessForbidden", + ResourceSource = typeof(AuditingResources), + }; } return _dbContext.AuditRecords diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs index 3b3fb1a4ac..0591b7b433 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs @@ -1,18 +1,23 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Web.Validation; using FSH.Modules.Auditing.Contracts.v1.GetAudits; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAudits; public sealed class GetAuditsQueryValidator : AbstractValidator { - public GetAuditsQueryValidator() + public GetAuditsQueryValidator( + IStringLocalizer localizer, + IStringLocalizer auditLocalizer) { - Include(new PagedQueryValidator()); + Include(new PagedQueryValidator(localizer)); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => auditLocalizer["Validation.DateRangeOrder"]); // Reject oversized windows up-front (user sees a 400, not a silent clamp). The handler // still clamps as defence in depth (e.g. when only one endpoint is supplied). @@ -21,6 +26,9 @@ public GetAuditsQueryValidator() !q.FromUtc.HasValue || !q.ToUtc.HasValue || (q.ToUtc.Value - q.FromUtc.Value) <= GetAuditsQueryHandler.MaxWindow) - .WithMessage($"Audit query window cannot exceed {GetAuditsQueryHandler.MaxWindow.TotalDays:0} days."); + // MaxWindowDays, not MaxWindow.TotalDays: the localizer formats arguments with + // string.Format under the current culture, and a double in a localized message is + // culture-sensitive by construction. An int cannot render a decimal separator. + .WithMessage(_ => auditLocalizer["Validation.WindowExceeded", GetAuditsQueryHandler.MaxWindowDays]); } } diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs index 3b00a8b5f9..d4039f0d25 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation; public sealed class GetAuditsByCorrelationQueryValidator : AbstractValidator { - public GetAuditsByCorrelationQueryValidator() + public GetAuditsByCorrelationQueryValidator(IStringLocalizer localizer) { RuleFor(q => q.CorrelationId) .NotEmpty(); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs index 8e8cdfbf13..74ffab9edc 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAuditsByTrace; public sealed class GetAuditsByTraceQueryValidator : AbstractValidator { - public GetAuditsByTraceQueryValidator() + public GetAuditsByTraceQueryValidator(IStringLocalizer localizer) { RuleFor(q => q.TraceId) .NotEmpty(); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs index 0b08a67f82..9d5754ea16 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs @@ -1,14 +1,16 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetExceptionAudits; public sealed class GetExceptionAuditsQueryValidator : AbstractValidator { - public GetExceptionAuditsQueryValidator() + public GetExceptionAuditsQueryValidator(IStringLocalizer localizer) { RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs index 05d69a7334..7bc1a471ec 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs @@ -1,14 +1,16 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetSecurityAudits; public sealed class GetSecurityAuditsQueryValidator : AbstractValidator { - public GetSecurityAuditsQueryValidator() + public GetSecurityAuditsQueryValidator(IStringLocalizer localizer) { RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.cs b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.cs new file mode 100644 index 0000000000..072a952413 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Auditing.Localization; + +/// Marker type binding IStringLocalizer<AuditingResources> to the Auditing resx catalog. +public sealed class AuditingResources; diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.pt-BR.resx b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.pt-BR.resx new file mode 100644 index 0000000000..327d056243 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.pt-BR.resx @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + O acesso a auditorias de outros inquilinos requer Permissions.AuditTrails.ViewCrossTenant. + + + O resumo de auditorias de outros inquilinos requer Permissions.AuditTrails.ViewCrossTenant. + + + FromUtc deve ser menor ou igual a ToUtc. + + + A janela de consulta de auditoria não pode exceder {0} dias. + + + A janela do resumo de auditoria não pode exceder {0} dias. + + + Registro de auditoria {0} não encontrado. + + diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.resx b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.resx new file mode 100644 index 0000000000..ed95eb4dda --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.resx @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant. + + + Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant. + + + FromUtc must be less than or equal to ToUtc. + + + Audit query window cannot exceed {0} days. + + + Audit summary window cannot exceed {0} days. + + + Audit record {0} not found. + + diff --git a/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj b/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj index 2c8b0a32de..ff7f4b1e61 100644 --- a/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj +++ b/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj @@ -3,7 +3,8 @@ FSH.Modules.Auditing FSH.Modules.Auditing - $(NoWarn);CA1031;CA1308;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1308;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs index c99a1b0c2a..9743e2c623 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Billing.Contracts.v1.Invoices; +using FSH.Modules.Billing.Localization; using FSH.Modules.Billing.Services; using Mediator; @@ -19,10 +20,17 @@ public async ValueTask Handle(GenerateInvoicesCommand command, Cancellation // Platform-wide invoice generation runs across EVERY tenant — it is a root-operator action. // A tenant admin (who also holds Billing.Manage) must not be able to trigger it. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; if (callerTenantId != MultitenancyConstants.Root.Id) { - throw new ForbiddenException("Only the root operator may generate invoices across tenants."); + throw new ForbiddenException("Only the root operator may generate invoices across tenants.") + { + MessageKey = "Billing.OnlyRootOperatorMayGenerateInvoices", + ResourceSource = typeof(BillingResources), + }; } return await billing.GenerateInvoicesForAllTenantsAsync(command.PeriodYear, command.PeriodMonth, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs index 62f4602d04..24bd651ab6 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Billing.Contracts.Dtos; using FSH.Modules.Billing.Contracts.v1.Invoices; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,10 @@ public async ValueTask Handle(GetInvoiceByIdQuery query, Cancellatio // BillingDbContext isn't tenant-filtered (raw DbContext for cross-tenant admin visibility): root // reads any invoice by id; a tenant caller is pinned to its own so it can't read another's. Mirrors GetSubscriptionQueryHandler. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var invoice = await dbContext.Invoices.AsNoTracking() @@ -30,7 +34,12 @@ public async ValueTask Handle(GetInvoiceByIdQuery query, Cancellatio i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found."); + ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found.") + { + MessageKey = "Billing.InvoiceNotFound", + MessageArgs = [query.InvoiceId], + ResourceSource = typeof(BillingResources), + }; return invoice.ToDto(); } diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs index 4b6c88b5a3..303831089a 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using FSH.Modules.Billing.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,10 @@ public async ValueTask Handle(GetInvoicePdfQuery query, Cancel // BillingDbContext is not tenant-filtered: root may download ANY tenant's invoice PDF; a tenant // caller is pinned to its own, so a cross-tenant id resolves to 404 and never leaks a PDF. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var invoice = await dbContext.Invoices.AsNoTracking() @@ -30,7 +34,12 @@ public async ValueTask Handle(GetInvoicePdfQuery query, Cancel i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found."); + ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found.") + { + MessageKey = "Billing.InvoiceNotFound", + MessageArgs = [query.InvoiceId], + ResourceSource = typeof(BillingResources), + }; var dto = invoice.ToDto(); var content = renderer.Render(dto); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs index ef07f2ebcd..a603fd8142 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs @@ -22,7 +22,10 @@ public async ValueTask> Handle(GetInvoicesQuery query, // BillingDbContext is not tenant-filtered: only root gets the cross-tenant view (optionally // narrowed via query.TenantId); every other caller is forced to its own tenant. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var tenantFilter = isRoot ? query.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs index 8485c5a373..f700bd5e29 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs @@ -20,7 +20,10 @@ public async ValueTask> Handle(GetMyInvoicesQuery quer ArgumentNullException.ThrowIfNull(query); var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var q = dbContext.Invoices.AsNoTracking() .Include(i => i.LineItems) diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs index da86c3187d..f71eb9cb40 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Billing.Contracts.v1.Plans; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetPlanTermQuery query, Cancella #pragma warning restore CA1308 var plan = await dbContext.Plans.AsNoTracking() .FirstOrDefaultAsync(p => p.Key == key && p.IsActive, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Active plan with key '{query.PlanKey}' not found."); + ?? throw new NotFoundException($"Active plan with key '{query.PlanKey}' not found.") + { + MessageKey = "Billing.ActivePlanNotFound", + MessageArgs = [query.PlanKey], + ResourceSource = typeof(BillingResources), + }; return new PlanTermResponse( plan.Id, diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs index 233b749bd2..03b274b5a6 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Billing.Contracts.v1.Plans; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -14,7 +15,12 @@ public async ValueTask Handle(UpdatePlanCommand command, CancellationToken ArgumentNullException.ThrowIfNull(command); var plan = await dbContext.Plans.FirstOrDefaultAsync(p => p.Id == command.PlanId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Plan {command.PlanId} not found."); + ?? throw new NotFoundException($"Plan {command.PlanId} not found.") + { + MessageKey = "Billing.PlanNotFound", + MessageArgs = [command.PlanId], + ResourceSource = typeof(BillingResources), + }; plan.Update(command.Name, command.MonthlyBasePrice, command.OverageRates, command.Interval, command.AnnualPrice); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs index 2348de90ff..0f1e368d59 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Billing.Contracts.v1.Subscriptions; using FSH.Modules.Billing.Data; using FSH.Modules.Billing.Domain; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,10 @@ public async ValueTask Handle(AssignSubscriptionCommand command, Cancellat // Only root may target an arbitrary tenant; a tenant caller is pinned to its own, so it can't // (re)assign or cancel another tenant's subscription via a foreign tenant id in the body. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var targetTenantId = isRoot ? command.TenantId : callerTenantId; @@ -29,7 +33,12 @@ public async ValueTask Handle(AssignSubscriptionCommand command, Cancellat var key = command.PlanKey.ToLowerInvariant(); #pragma warning restore CA1308 var plan = await dbContext.Plans.FirstOrDefaultAsync(p => p.Key == key && p.IsActive, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Active plan with key '{command.PlanKey}' not found."); + ?? throw new NotFoundException($"Active plan with key '{command.PlanKey}' not found.") + { + MessageKey = "Billing.ActivePlanNotFound", + MessageArgs = [command.PlanKey], + ResourceSource = typeof(BillingResources), + }; var now = DateTime.UtcNow; var current = await dbContext.Subscriptions diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs index 5e0f8c8538..6c55f9d7df 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs @@ -19,7 +19,10 @@ public sealed class GetSubscriptionQueryHandler( ArgumentNullException.ThrowIfNull(query); var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; // BillingDbContext is not tenant-filtered, so a tenant caller is pinned to its OWN // subscription and only root may pass an arbitrary tenant id (else cross-tenant reads). diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs index 930c39de9c..996a9cb910 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs @@ -21,7 +21,10 @@ public async ValueTask> Handle( // Only the root operator may capture usage for an arbitrary tenant; a tenant caller is pinned // to its own tenant so it can't fabricate another tenant's usage/overage snapshots. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var targetTenantId = isRoot ? command.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs index a49afdae72..395052b770 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs @@ -21,7 +21,10 @@ public async ValueTask> Handle(GetUsageSnapshots // UsageSnapshots is not tenant-filtered. Only the root operator may read across tenants // (optionally narrowed via query.TenantId); any other caller is forced to its own tenant. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var tenantFilter = isRoot ? query.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs index d1b92dca6e..e64e0c8663 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Billing.Contracts.v1.Wallets; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using FSH.Modules.Billing.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,29 @@ public async ValueTask Handle(ApproveTopupRequestCommand command, Cancella ArgumentNullException.ThrowIfNull(command); var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var request = await db.TopupRequests .FirstOrDefaultAsync(r => r.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Top-up request {command.Id} not found."); + ?? throw new NotFoundException($"Top-up request {command.Id} not found.") + { + MessageKey = "Billing.TopupRequestNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(BillingResources), + }; if (!isRoot && request.TenantId != callerTenantId) { - throw new UnauthorizedException("You can only approve top-up requests for your own tenant."); + throw new UnauthorizedException("You can only approve top-up requests for your own tenant.") + { + MessageKey = "Billing.CannotApproveTopupForOtherTenant", + ResourceSource = typeof(BillingResources), + }; } // For root, operate on the request's own tenant; for non-root, callerTenantId equals request.TenantId. diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs index b5621e5b6f..2d74244ce8 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs @@ -21,7 +21,10 @@ public async ValueTask Handle(CreateTopupRequestCommand command, Cancellat // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var requestedBy = currentUser.IsAuthenticated() ? currentUser.GetUserId().ToString() : null; var request = TopupRequest.Create(tenantId, command.Amount, "USD", command.Note, requestedBy); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs index d87440550b..e1ee030975 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs @@ -22,7 +22,10 @@ public async ValueTask> Handle(GetMyTopupRequests // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var q = dbContext.TopupRequests.AsNoTracking() .Where(r => r.TenantId == tenantId); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs index 37512d4582..d77dd0f535 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs @@ -20,7 +20,10 @@ public async ValueTask Handle(GetMyWalletQuery query, CancellationTok // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var wallet = await billingService.GetOrCreateWalletAsync(tenantId, "USD", cancellationToken).ConfigureAwait(false); return wallet.ToDto(); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs index 51adf97f43..8da6936a97 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs @@ -23,7 +23,10 @@ public async ValueTask> Handle(GetTopupRequestsQu // BillingDbContext is not tenant-filtered: only root gets the cross-tenant view (optionally // narrowed via query.TenantId); every other caller is forced to its own tenant. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var tenantFilter = isRoot ? query.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs index 34ff4ea4c4..f7f444f500 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Billing.Contracts; using FSH.Modules.Billing.Contracts.v1.Wallets; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,29 @@ public async ValueTask Handle(RejectTopupRequestCommand command, Cancellat ArgumentNullException.ThrowIfNull(command); var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var request = await db.TopupRequests .FirstOrDefaultAsync(r => r.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Top-up request {command.Id} not found."); + ?? throw new NotFoundException($"Top-up request {command.Id} not found.") + { + MessageKey = "Billing.TopupRequestNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(BillingResources), + }; if (!isRoot && request.TenantId != callerTenantId) { - throw new UnauthorizedException("You can only reject top-up requests for your own tenant."); + throw new UnauthorizedException("You can only reject top-up requests for your own tenant.") + { + MessageKey = "Billing.CannotRejectTopupForOtherTenant", + ResourceSource = typeof(BillingResources), + }; } if (request.Status != TopupRequestStatus.Pending) @@ -38,7 +51,12 @@ public async ValueTask Handle(RejectTopupRequestCommand command, Cancellat throw new CustomException( $"Top-up request {command.Id} cannot be rejected because it is {request.Status} (only Pending requests can be rejected).", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Billing.TopupRequestCannotBeRejected", + MessageArgs = [command.Id, request.Status], + ResourceSource = typeof(BillingResources), + }; } request.Reject(command.Reason); diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.cs b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.cs new file mode 100644 index 0000000000..1ab5911ad5 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Billing.Localization; + +/// Marker type binding IStringLocalizer<BillingResources> to the Billing resx catalog. +public sealed class BillingResources; diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx new file mode 100644 index 0000000000..1e7c1a962f --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Fatura {0} não encontrada. + + + Plano {0} não encontrado. + + + Plano {0} não encontrado para o tenant {1}. + + + Plano ativo com a chave '{0}' não encontrado. + + + Solicitação de recarga {0} não encontrada. + + + Solicitação de recarga {0} não encontrada ou não está pendente. + + + Apenas o operador raiz pode gerar faturas entre tenants. + + + Você só pode rejeitar solicitações de recarga do seu próprio tenant. + + + Você só pode aprovar solicitações de recarga do seu próprio tenant. + + + Não é possível rejeitar a solicitação de recarga {0} porque ela está {1} (somente solicitações Pendentes podem ser rejeitadas). + + diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx new file mode 100644 index 0000000000..7b6363b307 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Invoice {0} not found. + + + Plan {0} not found. + + + Plan {0} not found for tenant {1}. + + + Active plan with key '{0}' not found. + + + Top-up request {0} not found. + + + Top-up request {0} not found or not pending. + + + Only the root operator may generate invoices across tenants. + + + You can only reject top-up requests for your own tenant. + + + You can only approve top-up requests for your own tenant. + + + Top-up request {0} cannot be rejected because it is {1} (only Pending requests can be rejected). + + diff --git a/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj b/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj index 75f90198a1..429ae3d723 100644 --- a/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj +++ b/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj @@ -3,7 +3,7 @@ FSH.Modules.Billing FSH.Modules.Billing - $(NoWarn);CA1031;CA1711;CA1812;CA1859;S3267 + $(NoWarn);CA1031;CA1711;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs index 5b684ab0b4..0b30ade16c 100644 --- a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs +++ b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs @@ -8,6 +8,7 @@ using FSH.Modules.Billing.Contracts.Events; using FSH.Modules.Billing.Data; using FSH.Modules.Billing.Domain; +using FSH.Modules.Billing.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -75,7 +76,12 @@ public BillingService( } var plan = await _db.Plans.FirstOrDefaultAsync(p => p.Id == subscription.PlanId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Plan {subscription.PlanId} not found for tenant {tenantId}."); + ?? throw new NotFoundException($"Plan {subscription.PlanId} not found for tenant {tenantId}.") + { + MessageKey = "Billing.PlanNotFoundForTenant", + MessageArgs = [subscription.PlanId, tenantId], + ResourceSource = typeof(BillingResources), + }; var snapshots = await _usageReporter.CaptureForPeriodAsync(tenantId, periodYear, periodMonth, cancellationToken).ConfigureAwait(false); @@ -188,7 +194,12 @@ public async Task CreateTopupInvoiceAsync(string tenantId, Guid topupRe var request = await _db.TopupRequests .FirstOrDefaultAsync(r => r.Id == topupRequestId && r.TenantId == tenantId && r.Status == TopupRequestStatus.Pending, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Top-up request {topupRequestId} not found or not pending."); + ?? throw new NotFoundException($"Top-up request {topupRequestId} not found or not pending.") + { + MessageKey = "Billing.TopupRequestNotFoundOrNotPending", + MessageArgs = [topupRequestId], + ResourceSource = typeof(BillingResources), + }; var now = _timeProvider.GetUtcNow().UtcDateTime; var invoiceNumber = BuildTopupInvoiceNumber(tenantId, now, topupRequestId); @@ -289,13 +300,21 @@ public async Task VoidInvoiceAsync(Guid invoiceId, string? reason, CancellationT private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken cancellationToken) { var callerTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; return await _db.Invoices .FirstOrDefaultAsync(i => i.Id == invoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Invoice {invoiceId} not found."); + ?? throw new NotFoundException($"Invoice {invoiceId} not found.") + { + MessageKey = "Billing.InvoiceNotFound", + MessageArgs = [invoiceId], + ResourceSource = typeof(BillingResources), + }; } public async Task CreateSubscriptionInvoiceAsync( @@ -308,7 +327,12 @@ private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken c ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); var plan = await _db.Plans.FirstOrDefaultAsync(p => p.Id == planId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Plan {planId} not found for tenant {tenantId}."); + ?? throw new NotFoundException($"Plan {planId} not found for tenant {tenantId}.") + { + MessageKey = "Billing.PlanNotFoundForTenant", + MessageArgs = [planId, tenantId], + ResourceSource = typeof(BillingResources), + }; var termPrice = plan.TermPrice; if (termPrice.Amount <= 0m) diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs index 95130c2bcd..4d89c4bbcf 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -25,7 +26,12 @@ public async ValueTask Handle(CreateBrandCommand command, CancellationToke throw new CustomException( $"A brand with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.BrandNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } dbContext.Brands.Add(brand); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs index f32da24e77..34965b7575 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(DeleteBrandCommand command, CancellationToke var brand = await dbContext.Brands .FirstOrDefaultAsync(b => b.Id == command.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {command.BrandId} not found."); + ?? throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; dbContext.Brands.Remove(brand); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs index 0963c6a11d..2c62cdccc6 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetBrandByIdQuery query, CancellationTok .AsNoTracking() .FirstOrDefaultAsync(b => b.Id == query.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {query.BrandId} not found."); + ?? throw new NotFoundException($"Brand {query.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [query.BrandId], + ResourceSource = typeof(CatalogResources), + }; return new BrandDto( brand.Id, diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs index 061e97df26..a71cae0134 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Persistence; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -20,7 +21,12 @@ public async ValueTask Handle(RestoreBrandCommand command, CancellationTok .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(b => b.Id == command.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {command.BrandId} not found."); + ?? throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; brand.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs index 0e5ba4c14a..45416dde79 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(UpdateBrandCommand command, CancellationToke var brand = await dbContext.Brands .FirstOrDefaultAsync(b => b.Id == command.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {command.BrandId} not found."); + ?? throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; brand.Update(command.Name, command.Description, command.LogoUrl); @@ -29,7 +35,12 @@ public async ValueTask Handle(UpdateBrandCommand command, CancellationToke throw new CustomException( $"Another brand with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.AnotherBrandNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs index adc5edfb6e..451d15618d 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,12 @@ public async ValueTask Handle(CreateCategoryCommand command, CancellationT .ConfigureAwait(false); if (!parentExists) { - throw new NotFoundException($"Parent category {parentId} not found."); + throw new NotFoundException($"Parent category {parentId} not found.") + { + MessageKey = "Catalog.ParentCategoryNotFound", + MessageArgs = [parentId], + ResourceSource = typeof(CatalogResources), + }; } } @@ -36,7 +42,12 @@ public async ValueTask Handle(CreateCategoryCommand command, CancellationT throw new CustomException( $"A category with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.CategoryNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } dbContext.Categories.Add(category); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs index a596dce866..60d97e8e16 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(DeleteCategoryCommand command, CancellationT var category = await dbContext.Categories .FirstOrDefaultAsync(c => c.Id == command.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {command.CategoryId} not found."); + ?? throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; bool hasChildren = await dbContext.Categories .AnyAsync(c => c.ParentCategoryId == category.Id, cancellationToken) @@ -27,7 +33,11 @@ public async ValueTask Handle(DeleteCategoryCommand command, CancellationT throw new CustomException( "Cannot delete a category that has child categories. Move or remove the children first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.CategoryHasChildren", + ResourceSource = typeof(CatalogResources), + }; } dbContext.Categories.Remove(category); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs index 793fb49bcd..ce9552acac 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetCategoryByIdQuery query, Cancellat .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == query.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {query.CategoryId} not found."); + ?? throw new NotFoundException($"Category {query.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [query.CategoryId], + ResourceSource = typeof(CatalogResources), + }; return new CategoryDto(c.Id, c.Name, c.Slug, c.Description, c.ParentCategoryId, c.CreatedAtUtc, c.UpdatedAtUtc, c.DeletedOnUtc, c.DeletedBy); } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs index 3b29c8540d..ad36397147 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Persistence; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(RestoreCategoryCommand command, Cancellation .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(c => c.Id == command.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {command.CategoryId} not found."); + ?? throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; category.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs index 9fa8a5053d..61242c7e5f 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT var category = await dbContext.Categories .FirstOrDefaultAsync(c => c.Id == command.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {command.CategoryId} not found."); + ?? throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; if (command.ParentCategoryId is { } parentId) { @@ -26,7 +32,11 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( "A category cannot be its own parent.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Catalog.CategoryCannotBeOwnParent", + ResourceSource = typeof(CatalogResources), + }; } // Walk parent chain to detect cycles (parent → ancestor of self) @@ -39,7 +49,11 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( "Setting this parent would create a cycle.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Catalog.CategoryParentCycle", + ResourceSource = typeof(CatalogResources), + }; } cursor = await dbContext.Categories .Where(c => c.Id == cur) @@ -59,7 +73,12 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( $"Another category with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.AnotherCategoryNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs index 1bbb943009..dc2248f7f3 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Products.AddProductImage; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(AddProductImageCommand command, C var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; var image = product.AddImage(command.FileAssetId, command.Url); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs index a82c413ce0..6156c8f6e5 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(AdjustProductStockCommand command, Cancellati var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; try { @@ -25,7 +31,12 @@ public async ValueTask Handle(AdjustProductStockCommand command, Cancellati } catch (InvalidOperationException ex) { - throw new CustomException(ex.Message, (IEnumerable?)null, HttpStatusCode.Conflict); + throw new CustomException(ex.Message, (IEnumerable?)null, HttpStatusCode.Conflict) + { + MessageKey = "Catalog.StockAdjustmentNegative", + MessageArgs = [command.Delta, product.Stock], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs index 6f2b5b3d07..1070bc2833 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; using FSH.Modules.Catalog.Contracts.v1.Products; +using FSH.Modules.Catalog.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Catalog.Features.v1.Products.AdjustProductStock; public sealed class AdjustProductStockCommandValidator : AbstractValidator { - public AdjustProductStockCommandValidator() + public AdjustProductStockCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.ProductId).NotEmpty(); - RuleFor(x => x.Delta).NotEqual(0).WithMessage("Delta must be non-zero."); + RuleFor(x => x.Delta).NotEqual(0).WithMessage(_ => localizer["Validation.DeltaNonZero"]); } } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs index 8a101e9155..4abe3b18bc 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(ChangeProductPriceCommand command, Cancellat var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; product.ChangePrice(new Money(command.Amount, command.Currency)); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs index d3e75608a1..4a6701c851 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo .ConfigureAwait(false); if (!brandExists) { - throw new NotFoundException($"Brand {command.BrandId} not found."); + throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; } bool categoryExists = await dbContext.Categories @@ -29,7 +35,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo .ConfigureAwait(false); if (!categoryExists) { - throw new NotFoundException($"Category {command.CategoryId} not found."); + throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; } var product = Product.Create( @@ -49,7 +60,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo throw new CustomException( $"A product with SKU '{product.Sku}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.ProductSkuAlreadyExists", + MessageArgs = [product.Sku], + ResourceSource = typeof(CatalogResources), + }; } bool slugTaken = await dbContext.Products @@ -60,7 +76,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo throw new CustomException( $"A product with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.ProductNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } dbContext.Products.Add(product); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs index 01be205865..43f04c46f8 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -19,7 +20,12 @@ public async ValueTask Handle(DeleteProductCommand command, CancellationTo .IgnoreAutoIncludes() .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; dbContext.Products.Remove(product); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs index 7e36cc4cee..243b7362d1 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetProductByIdQuery query, Cancellatio .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == query.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {query.ProductId} not found."); + ?? throw new NotFoundException($"Product {query.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [query.ProductId], + ResourceSource = typeof(CatalogResources), + }; return product.ToDto(); } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs index db0db8006c..fbc0fc9865 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products.RemoveProductImage; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,12 +17,22 @@ public async ValueTask Handle(RemoveProductImageCommand command, Cancellat var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; // Domain throws InvalidOperationException for unknown imageId; translate to 404. if (!product.Images.Any(i => i.Id == command.ImageId)) { - throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}."); + throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}.") + { + MessageKey = "Catalog.ProductImageNotFound", + MessageArgs = [command.ImageId, command.ProductId], + ResourceSource = typeof(CatalogResources), + }; } product.RemoveImage(command.ImageId); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs index 7441d60e8e..5ec439d6f1 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products.ReorderProductImages; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(ReorderProductImagesCommand command, Cancell var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; product.ReorderImages(command.OrderedImageIds); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs index 235baee8d3..e6424dab84 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Persistence; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(RestoreProductCommand command, CancellationT .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; product.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs index 7b9232d82a..1d1522f078 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products.SetProductThumbnail; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,13 +17,23 @@ public async ValueTask Handle(SetProductThumbnailCommand command, Cancella var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; // Domain throws InvalidOperationException for unknown imageId; translate to a // framework-aware 404 so the API surfaces NotFound rather than a 500. if (!product.Images.Any(i => i.Id == command.ImageId)) { - throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}."); + throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}.") + { + MessageKey = "Catalog.ProductImageNotFound", + MessageArgs = [command.ImageId, command.ProductId], + ResourceSource = typeof(CatalogResources), + }; } product.SetThumbnail(command.ImageId); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs index 0d99b89eab..ff84ca4611 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; if (product.BrandId != command.BrandId) { @@ -26,7 +32,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo .ConfigureAwait(false); if (!brandExists) { - throw new NotFoundException($"Brand {command.BrandId} not found."); + throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; } } @@ -37,7 +48,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo .ConfigureAwait(false); if (!categoryExists) { - throw new NotFoundException($"Category {command.CategoryId} not found."); + throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; } } @@ -56,7 +72,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo throw new CustomException( $"Another product with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.AnotherProductNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.cs b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.cs new file mode 100644 index 0000000000..6258320c1c --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Catalog.Localization; + +/// Marker type binding IStringLocalizer<CatalogResources> to the Catalog resx catalog. +public sealed class CatalogResources; diff --git a/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.pt-BR.resx b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.pt-BR.resx new file mode 100644 index 0000000000..5f08aaceeb --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.pt-BR.resx @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Produto {0} não encontrado. + + + Marca {0} não encontrada. + + + Categoria {0} não encontrada. + + + Categoria pai {0} não encontrada. + + + Imagem {0} não encontrada no produto {1}. + + + Já existe um produto com o SKU '{0}'. + + + Já existe um produto com o nome '{0}'. + + + Já existe outro produto com o nome '{0}'. + + + Já existe uma marca com o nome '{0}'. + + + Já existe outra marca com o nome '{0}'. + + + Já existe uma categoria com o nome '{0}'. + + + Já existe outra categoria com o nome '{0}'. + + + Uma categoria não pode ser pai de si mesma. + + + Definir este pai criaria um ciclo. + + + Não é possível excluir uma categoria que possui categorias filhas. Mova ou remova as filhas primeiro. + + + O delta deve ser diferente de zero. + + + O ajuste de estoque de {0} resultaria em estoque negativo (atual: {1}). + + diff --git a/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.resx b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.resx new file mode 100644 index 0000000000..7594ae3200 --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.resx @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Product {0} not found. + + + Brand {0} not found. + + + Category {0} not found. + + + Parent category {0} not found. + + + Image {0} not found on product {1}. + + + A product with SKU '{0}' already exists. + + + A product with name '{0}' already exists. + + + Another product with name '{0}' already exists. + + + A brand with name '{0}' already exists. + + + Another brand with name '{0}' already exists. + + + A category with name '{0}' already exists. + + + Another category with name '{0}' already exists. + + + A category cannot be its own parent. + + + Setting this parent would create a cycle. + + + Cannot delete a category that has child categories. Move or remove the children first. + + + Delta must be non-zero. + + + Stock adjustment of {0} would result in negative stock (current: {1}). + + diff --git a/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj b/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj index f8f033ed05..72c4e33471 100644 --- a/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj +++ b/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj @@ -3,7 +3,8 @@ FSH.Modules.Catalog FSH.Modules.Catalog - $(NoWarn);CA1031;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs index c80fe1ccd4..a3011a9bc8 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -21,18 +22,26 @@ public async ValueTask Handle(AddChannelMembersCommand cmd, CancellationTo { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; // Members can invite to public channels they belong to; private channels require Admin. var caller = channel.RequireMember(currentUserId); if (channel.IsPrivate && caller.Role != ChannelMemberRole.Admin) { - throw new ForbiddenException("Only channel admins can add members to private channels."); + throw new ForbiddenException("Only channel admins can add members to private channels.") + { + MessageKey = "Chat.OnlyAdminsCanAddMembersToPrivateChannel", + ResourceSource = typeof(ChatResources), + }; } var newlyAdded = new List(); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs index a1654617a3..fd19956d49 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,11 +18,15 @@ public async ValueTask Handle(ArchiveChannelCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireAdmin(userId.ToString()); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs index 308dee6655..6de899d1a4 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs @@ -18,7 +18,7 @@ public async ValueTask Handle(CreateChannelCommand cmd, CancellationToken var userId = currentUser.GetUserId().ToString(); if (userId == Guid.Empty.ToString()) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; } var channel = ChatChannel.CreateChannel(cmd.Name, cmd.Description, cmd.IsPrivate, userId); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs index e29d15045c..bb9846dff4 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs @@ -20,7 +20,7 @@ public async ValueTask> Handle(DiscoverChannelsQu { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); int page = Math.Max(1, q.Page); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs index 0e52a8c295..8f3c1e7251 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -21,13 +22,17 @@ public async ValueTask Handle(FindOrCreateDmCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var otherIds = cmd.UserIds.Distinct(StringComparer.Ordinal).ToList(); if (otherIds.Any(id => string.Equals(id, currentUserId, StringComparison.Ordinal))) { - throw new CustomException("Cannot DM yourself.", (IEnumerable?)null, System.Net.HttpStatusCode.BadRequest); + throw new CustomException("Cannot DM yourself.", (IEnumerable?)null, System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Chat.CannotDmYourself", + ResourceSource = typeof(ChatResources), + }; } if (otherIds.Count == 1) diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs index d766d06112..0a36112071 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,13 +19,17 @@ public async ValueTask Handle(GetChannelByIdQuery q, CancellationTok { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.AsNoTracking() .FirstOrDefaultAsync(c => c.Id == q.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; // Private channels & DMs: must be a member. Public channels: anyone with View can see them. if (channel.IsPrivate) diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs index eca1da32f7..f448a41843 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs @@ -19,7 +19,7 @@ public async ValueTask> Handle(ListMyChannelsQuer { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); int page = Math.Max(1, q.Page); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs index 8aa1ea9b44..d65d6cb06e 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,19 +21,30 @@ public async ValueTask Handle(MarkChannelReadCommand cmd, CancellationToke { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); // Verify the marker message actually exists in this channel. var exists = await db.Messages .AnyAsync(m => m.Id == cmd.MessageId && m.ChannelId == cmd.ChannelId, cancellationToken) .ConfigureAwait(false); - if (!exists) throw new NotFoundException("Message not found in this channel."); + if (!exists) + { + throw new NotFoundException("Message not found in this channel.") + { + MessageKey = "Chat.MessageNotFoundInChannel", + ResourceSource = typeof(ChatResources), + }; + } channel.MarkRead(currentUserId, cmd.MessageId); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs index 0e0f06e757..a229200610 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -21,12 +22,16 @@ public async ValueTask Handle(RemoveChannelMemberCommand cmd, Cancellation { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; // Self-leave is always allowed for the current user. Removing someone else requires Admin. var isSelfLeave = string.Equals(cmd.UserId, currentUserId, StringComparison.Ordinal); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs index c878d4e8ce..3966e232d1 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,11 @@ public async ValueTask Handle(RestoreChannelCommand cmd, CancellationToken var channel = await db.Channels.IgnoreQueryFilters() .FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; if (!channel.IsDeleted) return Unit.Value; // idempotent channel.Restore(); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs index 980fdea854..d449df5f9e 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,11 +18,15 @@ public async ValueTask Handle(UpdateChannelCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireAdmin(userId.ToString()); channel.Rename(cmd.Name, cmd.Description); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs index b217e9b3c9..f38e7d6c6f 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Domain; +using FSH.Modules.Chat.Localization; namespace FSH.Modules.Chat.Features.v1.Internal; @@ -14,7 +15,11 @@ public static ChannelMember RequireMember(this ChatChannel channel, string userI { var member = channel.Members.FirstOrDefault(m => string.Equals(m.UserId, userId, StringComparison.Ordinal)); // Use NotFoundException (404) instead of Forbidden so non-members can't probe channel existence. - return member ?? throw new NotFoundException("Channel not found."); + return member ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; } public static ChannelMember RequireAdmin(this ChatChannel channel, string userId) @@ -22,7 +27,11 @@ public static ChannelMember RequireAdmin(this ChatChannel channel, string userId var member = channel.RequireMember(userId); if (member.Role != ChannelMemberRole.Admin) { - throw new ForbiddenException("Channel admin role required."); + throw new ForbiddenException("Channel admin role required.") + { + MessageKey = "Chat.ChannelAdminRoleRequired", + ResourceSource = typeof(ChatResources), + }; } return member; } diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs index 0127455da7..e29a70ec77 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using FSH.Modules.Identity.Contracts.Services; using Mediator; using Microsoft.AspNetCore.SignalR; @@ -23,16 +24,24 @@ public async ValueTask Handle(DeleteMessageCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); bool isModerator = await permissions diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs index 92ce8fad72..1c4065b1dd 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,25 @@ public async ValueTask Handle(EditMessageCommand cmd, CancellationToken ca { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; // Verify membership through the parent channel (don't leak existence to non-members). var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); message.Edit(cmd.Body, currentUserId); // domain enforces author-only diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs index 20f3213b5c..46253936d5 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,12 +23,16 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); var rows = await db.Messages diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs index e845953d78..088563bc68 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,12 +23,16 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); // Top-level only (no thread replies). Guid v7 monotonic → Id desc = time desc. diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs index 538e110717..814cc4c7d0 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,7 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); // Load the parent so we can authorize the caller through the channel. @@ -31,11 +32,19 @@ public async ValueTask> Handle( .Select(m => new { m.Id, m.ChannelId }) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Parent message not found."); + ?? throw new NotFoundException("Parent message not found.") + { + MessageKey = "Chat.ParentMessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == parent.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Parent message not found."); + ?? throw new NotFoundException("Parent message not found.") + { + MessageKey = "Chat.ParentMessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); IQueryable q = db.Messages diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs index 47a1e07442..3ea9c8f473 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,16 +21,24 @@ public async ValueTask Handle(PinMessageCommand cmd, CancellationToken can { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); message.Pin(currentUserId); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs index b2c2511dc3..40d375c123 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs @@ -10,6 +10,7 @@ using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using FSH.Modules.Chat.Services; using Mediator; using Microsoft.AspNetCore.SignalR; @@ -29,12 +30,16 @@ public async ValueTask Handle(SendMessageCommand cmd, CancellationTo { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); @@ -43,15 +48,27 @@ public async ValueTask Handle(SendMessageCommand cmd, CancellationTo { parent = await db.Messages.FirstOrDefaultAsync(m => m.Id == parentId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Parent message not found."); + ?? throw new NotFoundException("Parent message not found.") + { + MessageKey = "Chat.ParentMessageNotFound", + ResourceSource = typeof(ChatResources), + }; if (parent.ChannelId != channel.Id) { - throw new CustomException("Parent message belongs to a different channel.", (IEnumerable?)null, HttpStatusCode.BadRequest); + throw new CustomException("Parent message belongs to a different channel.", (IEnumerable?)null, HttpStatusCode.BadRequest) + { + MessageKey = "Chat.ParentMessageDifferentChannel", + ResourceSource = typeof(ChatResources), + }; } if (parent.ParentMessageId.HasValue) { // 1-level deep only per spec. - throw new CustomException("Cannot reply to a reply — threads are single-level only.", (IEnumerable?)null, HttpStatusCode.BadRequest); + throw new CustomException("Cannot reply to a reply — threads are single-level only.", (IEnumerable?)null, HttpStatusCode.BadRequest) + { + MessageKey = "Chat.CannotReplyToReply", + ResourceSource = typeof(ChatResources), + }; } } diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs index f385568961..0252dfe299 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs @@ -1,11 +1,13 @@ using FluentValidation; using FSH.Modules.Chat.Contracts.v1.Commands; +using FSH.Modules.Chat.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Chat.Features.v1.Messages.SendMessage; public sealed class SendMessageCommandValidator : AbstractValidator { - public SendMessageCommandValidator() + public SendMessageCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.ChannelId).NotEmpty(); // Body is optional when an attachment is present (Slack/Teams parity — "here's the file" with @@ -13,7 +15,7 @@ public SendMessageCommandValidator() RuleFor(x => x.Body) .NotEmpty() .When(x => x.Attachments is null || x.Attachments.Count == 0) - .WithMessage("Either a body or an attachment is required."); + .WithMessage(_ => localizer["Validation.BodyOrAttachmentRequired"]); RuleFor(x => x.Body) .MaximumLength(32_768) .When(x => !string.IsNullOrEmpty(x.Body)); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs index 65beefe000..5fc34f0b35 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,16 +21,24 @@ public async ValueTask Handle(UnpinMessageCommand cmd, CancellationToken c { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); message.Unpin(currentUserId); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs index bd22475b8a..b85d987840 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,25 @@ public async ValueTask Handle(AddReactionCommand cmd, CancellationToken ca { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; // Authorize through the parent channel — don't leak existence to non-members. var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); var added = message.AddReaction(currentUserId, cmd.Emoji); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs index 996127ed01..695085bc4e 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,16 +21,24 @@ public async ValueTask Handle(RemoveReactionCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); if (!message.RemoveReaction(currentUserId, cmd.Emoji)) diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs index 0f1ce68f26..e68b402953 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs @@ -23,7 +23,7 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); int page = Math.Max(1, query.Page); diff --git a/src/Modules/Chat/Modules.Chat/Localization/ChatResources.cs b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.cs new file mode 100644 index 0000000000..bfdb7f34ea --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Chat.Localization; + +/// Marker type binding IStringLocalizer<ChatResources> to the Chat resx catalog. +public sealed class ChatResources; diff --git a/src/Modules/Chat/Modules.Chat/Localization/ChatResources.pt-BR.resx b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.pt-BR.resx new file mode 100644 index 0000000000..0713cbd0b5 --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.pt-BR.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Canal não encontrado. + + + É necessário ser administrador do canal. + + + Apenas administradores do canal podem adicionar membros a canais privados. + + + Mensagem não encontrada. + + + Mensagem não encontrada neste canal. + + + Mensagem pai não encontrada. + + + A mensagem pai pertence a outro canal. + + + Não é possível responder a uma resposta; as threads têm apenas um nível. + + + Não é possível iniciar uma conversa direta consigo mesmo. + + + É necessário informar um texto ou um anexo. + + diff --git a/src/Modules/Chat/Modules.Chat/Localization/ChatResources.resx b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.resx new file mode 100644 index 0000000000..5319e61aa1 --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Channel not found. + + + Channel admin role required. + + + Only channel admins can add members to private channels. + + + Message not found. + + + Message not found in this channel. + + + Parent message not found. + + + Parent message belongs to a different channel. + + + Cannot reply to a reply — threads are single-level only. + + + Cannot DM yourself. + + + Either a body or an attachment is required. + + diff --git a/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj b/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj index 80aba39d01..12383f226e 100644 --- a/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj +++ b/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj @@ -3,7 +3,8 @@ FSH.Modules.Chat FSH.Modules.Chat - $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267;S2094 diff --git a/src/Modules/Files/Modules.Files/Domain/FileAsset.cs b/src/Modules/Files/Modules.Files/Domain/FileAsset.cs index f8c8e150d7..f1630df52c 100644 --- a/src/Modules/Files/Modules.Files/Domain/FileAsset.cs +++ b/src/Modules/Files/Modules.Files/Domain/FileAsset.cs @@ -3,6 +3,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Files.Contracts.v1.DTOs; using FSH.Modules.Files.Domain.Events; +using FSH.Modules.Files.Localization; namespace FSH.Modules.Files.Domain; @@ -87,7 +88,12 @@ public void MarkAvailable(long actualSize, ScanStatus scanResult) throw new CustomException( $"Cannot finalize file in status {Status}.", errors: null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Files.CannotFinalizeInStatus", + MessageArgs = [Status], + ResourceSource = typeof(FilesResources), + }; } if (actualSize <= 0) { @@ -126,7 +132,12 @@ public void ChangeVisibility(Visibility next) throw new CustomException( $"Cannot change visibility while file is in status {Status}.", errors: null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Files.CannotChangeVisibilityInStatus", + MessageArgs = [Status], + ResourceSource = typeof(FilesResources), + }; } if (Visibility == next) return; Visibility = next; diff --git a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs index 91f37c1f02..9713652697 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs @@ -7,6 +7,7 @@ using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; using FSH.Modules.Files.Features.v1.Internal; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -29,21 +30,38 @@ public async ValueTask Handle(ChangeFileVisibilityCommand cmd, Can throw new CustomException( $"Unknown visibility value '{cmd.Visibility}'.", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Files.UnknownVisibility", + MessageArgs = [cmd.Visibility], + ResourceSource = typeof(FilesResources), + }; } var f = await db.FileAssets .FirstOrDefaultAsync(x => x.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new ForbiddenException("no policy"); + ?? throw new ForbiddenException("no policy") + { + MessageKey = "Files.NoAccessPolicy", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanChangeVisibilityAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new ForbiddenException("not allowed to change this file's visibility"); + throw new ForbiddenException("not allowed to change this file's visibility") + { + MessageKey = "Files.NotAllowedToChangeVisibility", + ResourceSource = typeof(FilesResources), + }; } f.ChangeVisibility(cmd.Visibility); diff --git a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs index 7cf3f3d510..07057289ab 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; using FSH.Modules.Files.Contracts.v1.Commands; +using FSH.Modules.Files.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Files.Features.v1.ChangeVisibility; public sealed class ChangeFileVisibilityCommandValidator : AbstractValidator { - public ChangeFileVisibilityCommandValidator() + public ChangeFileVisibilityCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.FileAssetId).NotEmpty(); RuleFor(x => x.Visibility) .IsInEnum() - .WithMessage("Visibility must be Public or Private."); + .WithMessage(_ => localizer["Files.VisibilityInvalid"]); } } diff --git a/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs index 95190356a0..cfea26f961 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Files.Contracts; using FSH.Modules.Files.Contracts.v1.Commands; using FSH.Modules.Files.Data; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,15 +23,27 @@ public async ValueTask Handle(DeleteFileCommand cmd, CancellationToken can var f = await db.FileAssets .FirstOrDefaultAsync(x => x.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new ForbiddenException("no policy"); + ?? throw new ForbiddenException("no policy") + { + MessageKey = "Files.NoAccessPolicy", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanDeleteAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new ForbiddenException("not allowed to delete this file"); + throw new ForbiddenException("not allowed to delete this file") + { + MessageKey = "Files.NotAllowedToDelete", + ResourceSource = typeof(FilesResources), + }; } // Soft-delete: AuditableEntitySaveChangesInterceptor sets IsDeleted/DeletedOnUtc/DeletedBy on diff --git a/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs index 8288acbe42..26a72648d9 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs @@ -12,6 +12,7 @@ using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; using FSH.Modules.Files.Features.v1.Internal; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -30,25 +31,44 @@ public sealed class FinalizeUploadCommandHandler( public async ValueTask Handle(FinalizeUploadCommand cmd, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(cmd); - var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant"); + var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; var userId = currentUser.GetUserId().ToString(); var asset = await db.FileAssets .FirstOrDefaultAsync(f => f.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; if (!string.Equals(asset.CreatedByUserId, userId, StringComparison.Ordinal)) { - throw new ForbiddenException("not your pending file"); + throw new ForbiddenException("not your pending file") + { + MessageKey = "Files.NotYourPendingFile", + ResourceSource = typeof(FilesResources), + }; } if (asset.Status != FileAssetStatus.PendingUpload) { - throw new CustomException("file already finalized", (IEnumerable?)null, HttpStatusCode.Conflict); + throw new CustomException("file already finalized", (IEnumerable?)null, HttpStatusCode.Conflict) + { + MessageKey = "Files.AlreadyFinalized", + ResourceSource = typeof(FilesResources), + }; } var head = await storage.HeadObjectAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false) - ?? throw new CustomException("upload not received", (IEnumerable?)null, HttpStatusCode.Conflict); + ?? throw new CustomException("upload not received", (IEnumerable?)null, HttpStatusCode.Conflict) + { + MessageKey = "Files.UploadNotReceived", + ResourceSource = typeof(FilesResources), + }; // Allow declared+1% slack (S3 may differ slightly on multipart). Reject larger sizes. var maxAllowed = asset.SizeBytes + Math.Max(1024L, asset.SizeBytes / 100); @@ -60,7 +80,12 @@ public async ValueTask Handle(FinalizeUploadCommand cmd, Cancellat throw new CustomException( $"uploaded size ({head.SizeBytes}) exceeds declared ({asset.SizeBytes})", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.UploadedSizeExceedsDeclared", + MessageArgs = [head.SizeBytes, asset.SizeBytes], + ResourceSource = typeof(FilesResources), + }; } if (!string.Equals(head.ContentType, asset.ContentType, StringComparison.OrdinalIgnoreCase)) @@ -71,7 +96,11 @@ public async ValueTask Handle(FinalizeUploadCommand cmd, Cancellat throw new CustomException( "uploaded content-type mismatch", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.ContentTypeMismatch", + ResourceSource = typeof(FilesResources), + }; } var scanResult = await scanner.ScanAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs index 7dd4d06b64..8a0adace50 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Files.Contracts.v1.DTOs; using FSH.Modules.Files.Contracts.v1.Queries; using FSH.Modules.Files.Data; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -27,16 +28,28 @@ public async ValueTask Handle(GetFileDownloadUrlQuery var f = await db.FileAssets.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == q.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanReadAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new NotFoundException("file not found"); + throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; } var ttl = TimeSpan.FromMinutes(options.Value.DownloadUrlTtlMinutes); diff --git a/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs index 32f8366244..cf8a85a80f 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs @@ -7,6 +7,7 @@ using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; using FSH.Modules.Files.Features.v1.Internal; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -27,16 +28,29 @@ public async ValueTask Handle(GetFileMetadataQuery q, Cancellation var f = await db.FileAssets.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == q.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); + // don't leak existence on missing policy var policy = policies.Resolve(f.OwnerType) - ?? throw new NotFoundException("file not found"); // don't leak existence on missing policy + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanReadAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new NotFoundException("file not found"); + throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; } // Public files get a durable URL safe to persist long-term, while private files mint a diff --git a/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs index 935c5a13ed..72b99dccb1 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs @@ -24,7 +24,10 @@ public async ValueTask> Handle(ListMyFilesQuery var userId = currentUser.GetUserId().ToString(); if (string.IsNullOrEmpty(userId) || userId == Guid.Empty.ToString()) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; } var page = Math.Max(1, q.Page); diff --git a/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs index c5d837cd14..4ba9696d50 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs @@ -9,6 +9,7 @@ using FSH.Modules.Files.Contracts.v1.DTOs; using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.Extensions.Options; @@ -28,17 +29,28 @@ public async ValueTask Handle(RequestUploadUrlCommand c { ArgumentNullException.ThrowIfNull(cmd); - var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant"); + var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; var userId = currentUser.GetUserId(); if (userId == Guid.Empty) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; } // Category lookup + extension/size validation. if (!options.Value.Categories.TryGetValue(cmd.Category, out var category)) { - throw new CustomException($"Unknown category '{cmd.Category}'.", (IEnumerable?)null, HttpStatusCode.BadRequest); + throw new CustomException($"Unknown category '{cmd.Category}'.", (IEnumerable?)null, HttpStatusCode.BadRequest) + { + MessageKey = "Files.UnknownCategory", + MessageArgs = [cmd.Category], + ResourceSource = typeof(FilesResources), + }; } var extension = Path.GetExtension(cmd.FileName); @@ -48,7 +60,12 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"Extension '{extension}' not allowed for category '{cmd.Category}'.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.ExtensionNotAllowed", + MessageArgs = [extension, cmd.Category], + ResourceSource = typeof(FilesResources), + }; } if (cmd.SizeBytes > category.MaxBytes) @@ -56,15 +73,29 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"File exceeds max size of {category.MaxBytes} bytes for category '{cmd.Category}'.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.FileExceedsMaxSize", + MessageArgs = [category.MaxBytes, cmd.Category], + ResourceSource = typeof(FilesResources), + }; } // Authorization: policy must exist and allow the attach. var policy = policies.Resolve(cmd.OwnerType) - ?? throw new ForbiddenException($"No file access policy registered for owner type '{cmd.OwnerType}'."); + ?? throw new ForbiddenException($"No file access policy registered for owner type '{cmd.OwnerType}'.") + { + MessageKey = "Files.NoPolicyForOwnerType", + MessageArgs = [cmd.OwnerType], + ResourceSource = typeof(FilesResources), + }; if (!await policy.CanAttachAsync(cmd.OwnerId, userId.ToString(), cancellationToken).ConfigureAwait(false)) { - throw new ForbiddenException("Not allowed to attach files to this owner."); + throw new ForbiddenException("Not allowed to attach files to this owner.") + { + MessageKey = "Files.NotAllowedToAttach", + ResourceSource = typeof(FilesResources), + }; } // Quota pre-check (no debit yet — debit happens on finalize with actual bytes). @@ -74,7 +105,12 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"Storage quota exceeded ({quotaCheck.CurrentUsage}/{quotaCheck.Limit} bytes).", (IEnumerable?)null, - (HttpStatusCode)507); + (HttpStatusCode)507) + { + MessageKey = "Files.StorageQuotaExceeded", + MessageArgs = [quotaCheck.CurrentUsage, quotaCheck.Limit], + ResourceSource = typeof(FilesResources), + }; } // Generate id + storage key + presigned URL. diff --git a/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs index 724cd21e70..561948afce 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Files.Contracts.v1.Commands; using FSH.Modules.Files.Data; +using FSH.Modules.Files.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,11 @@ public async ValueTask Handle(RestoreFileCommand cmd, CancellationToken ca .IgnoreQueryFilters() .FirstOrDefaultAsync(x => x.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; if (!f.IsDeleted) { diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.cs b/src/Modules/Files/Modules.Files/Localization/FilesResources.cs new file mode 100644 index 0000000000..0c66119c6c --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Files.Localization; + +/// Marker type binding IStringLocalizer<FilesResources> to the Files resx catalog. +public sealed class FilesResources; diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx b/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx new file mode 100644 index 0000000000..14feeb0afe --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Não é possível finalizar o arquivo no status {0}. + + + Não é possível alterar a visibilidade enquanto o arquivo está no status {0}. + + + Valor de visibilidade desconhecido '{0}'. + + + Arquivo não encontrado. + + + Nenhuma política de acesso a arquivos registrada. + + + Você não tem permissão para alterar a visibilidade deste arquivo. + + + Você não tem permissão para excluir este arquivo. + + + Este arquivo pendente não pertence a você. + + + O arquivo já foi finalizado. + + + O upload não foi recebido. + + + O tamanho enviado ({0}) excede o tamanho declarado ({1}). + + + O tipo de conteúdo enviado não corresponde. + + + Categoria desconhecida '{0}'. + + + A extensão '{0}' não é permitida para a categoria '{1}'. + + + O arquivo excede o tamanho máximo de {0} bytes para a categoria '{1}'. + + + Nenhuma política de acesso a arquivos registrada para o tipo de proprietário '{0}'. + + + Você não tem permissão para anexar arquivos a este proprietário. + + + Cota de armazenamento excedida ({0}/{1} bytes). + + + A visibilidade deve ser Pública ou Privada. + + diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.resx b/src/Modules/Files/Modules.Files/Localization/FilesResources.resx new file mode 100644 index 0000000000..e0116adf4e --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.resx @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cannot finalize file in status {0}. + + + Cannot change visibility while file is in status {0}. + + + Unknown visibility value '{0}'. + + + File not found. + + + No file access policy is registered. + + + You are not allowed to change this file's visibility. + + + You are not allowed to delete this file. + + + This pending file does not belong to you. + + + File is already finalized. + + + Upload was not received. + + + Uploaded size ({0}) exceeds the declared size ({1}). + + + Uploaded content type does not match. + + + Unknown category '{0}'. + + + Extension '{0}' is not allowed for category '{1}'. + + + File exceeds the maximum size of {0} bytes for category '{1}'. + + + No file access policy is registered for owner type '{0}'. + + + You are not allowed to attach files to this owner. + + + Storage quota exceeded ({0}/{1} bytes). + + + Visibility must be Public or Private. + + diff --git a/src/Modules/Files/Modules.Files/Modules.Files.csproj b/src/Modules/Files/Modules.Files/Modules.Files.csproj index d259693c85..83d34fe5ff 100644 --- a/src/Modules/Files/Modules.Files/Modules.Files.csproj +++ b/src/Modules/Files/Modules.Files/Modules.Files.csproj @@ -3,7 +3,8 @@ FSH.Modules.Files FSH.Modules.Files - $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267;S2094 diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs index feb1a05203..fc8a3185a9 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -32,7 +33,12 @@ public async ValueTask Handle(AddUsersToGroupCommand co if (!groupExists) { - throw new NotFoundException($"Group with ID '{command.GroupId}' not found."); + throw new NotFoundException($"Group with ID '{command.GroupId}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [command.GroupId], + ResourceSource = typeof(IdentityResources), + }; } // Validate user IDs exist @@ -44,7 +50,12 @@ public async ValueTask Handle(AddUsersToGroupCommand co var invalidUserIds = command.UserIds.Except(existingUserIds).ToList(); if (invalidUserIds.Count > 0) { - throw new NotFoundException($"Users not found: {string.Join(", ", invalidUserIds)}"); + throw new NotFoundException($"Users not found: {string.Join(", ", invalidUserIds)}") + { + MessageKey = "Identity.UsersNotFound", + MessageArgs = [string.Join(", ", invalidUserIds)], + ResourceSource = typeof(IdentityResources), + }; } // Get existing memberships diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs index e90a9a564d..6c15fbcb9f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs @@ -1,18 +1,20 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup; public sealed class AddUsersToGroupCommandValidator : AbstractValidator { - public AddUsersToGroupCommandValidator() + public AddUsersToGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.GroupId) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.UserIds) - .NotEmpty().WithMessage("At least one user ID is required.") + .NotEmpty().WithMessage(_ => localizer["Validation.AtLeastOneUserIdRequired"]) .Must(ids => ids.All(id => !string.IsNullOrWhiteSpace(id))) - .WithMessage("User IDs cannot be empty or whitespace."); + .WithMessage(_ => localizer["Validation.UserIdsNotEmptyOrWhitespace"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs index d0e1493737..c933d7512b 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -30,7 +31,12 @@ public async ValueTask Handle(CreateGroupCommand command, Cancellation if (nameExists) { - throw new CustomException($"Group with name '{command.Name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict); + throw new CustomException($"Group with name '{command.Name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict) + { + MessageKey = "Identity.GroupNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(IdentityResources), + }; } // Validate role IDs exist — fetch Id+Name in a single query to avoid a second roundtrip later @@ -46,7 +52,12 @@ public async ValueTask Handle(CreateGroupCommand command, Cancellation var invalidRoleIds = command.RoleIds.Except(resolvedRoles.Select(r => r.Id)).ToList(); if (invalidRoleIds.Count > 0) { - throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}"); + throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}") + { + MessageKey = "Identity.RolesNotFoundWithIds", + MessageArgs = [string.Join(", ", invalidRoleIds)], + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs index 42a8dd668e..f112b51976 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup; public sealed class CreateGroupCommandValidator : AbstractValidator { - public CreateGroupCommandValidator() + public CreateGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Name) - .NotEmpty().WithMessage("Group name is required.") - .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupNameRequired"]) + .MaximumLength(256).WithMessage(_ => localizer["Validation.GroupNameMaxLength"]); RuleFor(x => x.Description) - .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + .MaximumLength(1024).WithMessage(_ => localizer["Validation.DescriptionMaxLength"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs index 37626d12d4..3143680330 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -27,11 +28,20 @@ public async ValueTask Handle(DeleteGroupCommand command, CancellationToke var group = await _dbContext.Groups .FirstOrDefaultAsync(g => g.Id == command.Id, cancellationToken) - ?? throw new NotFoundException($"Group with ID '{command.Id}' not found."); + ?? throw new NotFoundException($"Group with ID '{command.Id}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(IdentityResources), + }; if (group.IsSystemGroup) { - throw new ForbiddenException("System groups cannot be deleted."); + throw new ForbiddenException("System groups cannot be deleted.") + { + MessageKey = "Identity.SystemGroupsCannotBeDeleted", + ResourceSource = typeof(IdentityResources), + }; } // Snapshot members before delete; soft-delete flips IsDeleted but membership rows diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs index 4805b8769a..4d2834c5a7 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.DeleteGroup; public sealed class DeleteGroupCommandValidator : AbstractValidator { - public DeleteGroupCommandValidator() + public DeleteGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs index bc29827213..3f6683ae62 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupById; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,12 @@ public async ValueTask Handle(GetGroupByIdQuery query, CancellationTok .AsNoTracking() .Include(g => g.GroupRoles) .FirstOrDefaultAsync(g => g.Id == query.Id, cancellationToken) - ?? throw new NotFoundException($"Group with ID '{query.Id}' not found."); + ?? throw new NotFoundException($"Group with ID '{query.Id}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [query.Id], + ResourceSource = typeof(IdentityResources), + }; var memberCount = await _dbContext.UserGroups .AsNoTracking() diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs index a8689ceb7b..1de6594088 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupMembers; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -25,7 +26,12 @@ public async ValueTask> Handle(GetGroupMembersQuery if (!groupExists) { - throw new NotFoundException($"Group with ID '{query.GroupId}' not found."); + throw new NotFoundException($"Group with ID '{query.GroupId}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [query.GroupId], + ResourceSource = typeof(IdentityResources), + }; } // Get memberships with user info diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs index e10d1eee9c..21e614d0ab 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -28,14 +29,23 @@ public async ValueTask Handle(RemoveUserFromGroupCommand command, Cancella if (membership is null) { - throw new NotFoundException($"User '{command.UserId}' is not a member of group '{command.GroupId}'."); + throw new NotFoundException($"User '{command.UserId}' is not a member of group '{command.GroupId}'.") + { + MessageKey = "Identity.UserNotMemberOfGroup", + MessageArgs = [command.UserId, command.GroupId], + ResourceSource = typeof(IdentityResources), + }; } // Default groups (e.g. seeded "All Users") require every tenant user to be a member, so // removing one breaks that invariant and leaves later registrants in a half-populated group. if (membership.Group is not null && membership.Group.IsDefault) { - throw new ForbiddenException("Users cannot be removed from a default group."); + throw new ForbiddenException("Users cannot be removed from a default group.") + { + MessageKey = "Identity.CannotRemoveFromDefaultGroup", + ResourceSource = typeof(IdentityResources), + }; } _dbContext.UserGroups.Remove(membership); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs index da5ce2bd3d..418b3210ff 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup; public sealed class RemoveUserFromGroupCommandValidator : AbstractValidator { - public RemoveUserFromGroupCommandValidator() + public RemoveUserFromGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.GroupId) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs index 37d6e00c1a..f8c7b331d9 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -33,7 +34,11 @@ public async ValueTask Handle(UpdateGroupCommand command, Cancellation // assignments are all part of the seed contract that the startup syncer relies on. if (group.IsSystemGroup) { - throw new ForbiddenException("System groups cannot be modified."); + throw new ForbiddenException("System groups cannot be modified.") + { + MessageKey = "Identity.SystemGroupsCannotBeModified", + ResourceSource = typeof(IdentityResources), + }; } await ValidateUniqueNameAsync(command.Id, command.Name, cancellationToken); @@ -69,7 +74,12 @@ private async Task GetGroupAsync(Guid id, CancellationToken cancellationT return await _dbContext.Groups .Include(g => g.GroupRoles) .FirstOrDefaultAsync(g => g.Id == id, cancellationToken) - ?? throw new NotFoundException($"Group with ID '{id}' not found."); + ?? throw new NotFoundException($"Group with ID '{id}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [id], + ResourceSource = typeof(IdentityResources), + }; } private async Task ValidateUniqueNameAsync(Guid excludeId, string name, CancellationToken cancellationToken) @@ -79,7 +89,12 @@ private async Task ValidateUniqueNameAsync(Guid excludeId, string name, Cancella if (nameExists) { - throw new CustomException($"Group with name '{name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict); + throw new CustomException($"Group with name '{name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict) + { + MessageKey = "Identity.GroupNameAlreadyExists", + MessageArgs = [name], + ResourceSource = typeof(IdentityResources), + }; } } @@ -98,7 +113,12 @@ private async Task ValidateRoleIdsAsync(IReadOnlyList? roleIds, Cancella var invalidRoleIds = roleIds.Except(existingRoleIds).ToList(); if (invalidRoleIds.Count > 0) { - throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}"); + throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}") + { + MessageKey = "Identity.RolesNotFoundWithIds", + MessageArgs = [string.Join(", ", invalidRoleIds)], + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs index 4c111e0c05..3442077ddc 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs @@ -1,20 +1,22 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; public sealed class UpdateGroupCommandValidator : AbstractValidator { - public UpdateGroupCommandValidator() + public UpdateGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.Name) - .NotEmpty().WithMessage("Group name is required.") - .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupNameRequired"]) + .MaximumLength(256).WithMessage(_ => localizer["Validation.GroupNameMaxLength"]); RuleFor(x => x.Description) - .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + .MaximumLength(1024).WithMessage(_ => localizer["Validation.DescriptionMaxLength"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs index d731e131fb..08509554e3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Impersonation.EndImpersonation; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.IdentityModel.Tokens.Jwt; @@ -66,7 +67,11 @@ public async ValueTask Handle( throw new CustomException( "current session is not an impersonation session", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.NotAnImpersonationSession", + ResourceSource = typeof(IdentityResources), + }; } var impersonatedUserId = _currentUser.GetUserId().ToString(); @@ -93,7 +98,11 @@ public async ValueTask Handle( if (actorClaimsResult is null) { - throw new NotFoundException("original actor not found"); + throw new NotFoundException("original actor not found") + { + MessageKey = "Identity.OriginalActorNotFound", + ResourceSource = typeof(IdentityResources), + }; } var (subject, actorClaims) = actorClaimsResult.Value; diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs index 65b8377a41..20e4b57dfe 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs @@ -20,7 +20,10 @@ public async ValueTask> Handle( ArgumentNullException.ThrowIfNull(request); var callerTenant = currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context"); + ?? throw new UnauthorizedException("missing tenant context") + { + MessageKey = "Error.InvalidTenant", + }; var isRoot = string.Equals(callerTenant, MultitenancyConstants.Root.Id, StringComparison.Ordinal); // Tenant scoping: root operators target any tenant; tenant admins are locked to their diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs index 912dff296d..8dd4bac96f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs @@ -1,5 +1,7 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Impersonation.GetImpersonationGrants; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Impersonation.GetImpersonationGrants; @@ -7,11 +9,11 @@ public sealed class GetImpersonationGrantsQueryValidator : AbstractValidator localizer) { RuleFor(q => q.Take) .GreaterThan(0) .LessThanOrEqualTo(MaxTake) - .WithMessage($"Take must be between 1 and {MaxTake}."); + .WithMessage(_ => localizer["Validation.ImpersonationTakeRange", MaxTake]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs index 9dd7a8a24a..aaa859e5f0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Impersonation; using FSH.Modules.Identity.Contracts.v1.Impersonation.RevokeImpersonationGrant; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; @@ -31,20 +32,31 @@ public async ValueTask Handle( var callerUserId = currentUser.GetUserId().ToString(); var callerTenantId = currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context"); + ?? throw new UnauthorizedException("missing tenant context") + { + MessageKey = "Error.InvalidTenant", + }; var isRoot = string.Equals(callerTenantId, MultitenancyConstants.Root.Id, StringComparison.Ordinal); // Enforce visibility before revoking: tenant admins may only revoke grants in their own // tenant. Cross-tenant grants return 404 (not 403) so existence isn't confirmed out of scope. var grant = await grantService.GetByIdAsync(request.GrantId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException("impersonation grant not found"); + ?? throw new NotFoundException("impersonation grant not found") + { + MessageKey = "Identity.ImpersonationGrantNotFound", + ResourceSource = typeof(IdentityResources), + }; var withinTenant = string.Equals(grant.ImpersonatedTenantId, callerTenantId, StringComparison.Ordinal) || string.Equals(grant.ActorTenantId, callerTenantId, StringComparison.Ordinal); if (!isRoot && !withinTenant) { - throw new NotFoundException("impersonation grant not found"); + throw new NotFoundException("impersonation grant not found") + { + MessageKey = "Identity.ImpersonationGrantNotFound", + ResourceSource = typeof(IdentityResources), + }; } var updated = await grantService.RevokeAsync( diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs index 69647afe7a..6a2f8cafd3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs @@ -1,5 +1,7 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Impersonation.StartImpersonation; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Impersonation.StartImpersonation; @@ -12,7 +14,7 @@ public sealed class StartImpersonationCommandValidator : AbstractValidator public const int MaxImpersonationMinutes = 60; - public StartImpersonationCommandValidator() + public StartImpersonationCommandValidator(IStringLocalizer localizer) { RuleFor(p => p.TargetUserId) .Cascade(CascadeMode.Stop) @@ -25,7 +27,7 @@ public StartImpersonationCommandValidator() RuleFor(p => p.DurationMinutes!.Value) .GreaterThan(0) .LessThanOrEqualTo(MaxImpersonationMinutes) - .WithMessage($"Duration must be between 1 and {MaxImpersonationMinutes} minutes.") + .WithMessage(_ => localizer["Validation.ImpersonationDurationRange", MaxImpersonationMinutes]) .When(p => p.DurationMinutes.HasValue); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs index bf213c86a5..a015186dbc 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.DeleteRole; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.DeleteRole; public sealed class DeleteRoleCommandValidator : AbstractValidator { - public DeleteRoleCommandValidator() + public DeleteRoleCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Role ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.RoleIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs index 183ad47f34..5a51837f1a 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.GetRoles; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.GetRoles; public sealed class GetRolesQueryValidator : AbstractValidator { - public GetRolesQueryValidator() + public GetRolesQueryValidator(IStringLocalizer localizer) { RuleFor(x => x.PageNumber) - .GreaterThanOrEqualTo(1).WithMessage("Page number must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(x => x.PageSize) - .GreaterThanOrEqualTo(1).WithMessage("Page size must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageSizeMinimum"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs index 70fb1e8cc6..6083ff757e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs @@ -9,6 +9,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -57,10 +58,18 @@ public async Task> GetRolesAsync( CancellationToken cancellationToken = default) { if (roleManager is null) - throw new NotFoundException("RoleManager not resolved. Check Identity registration."); + throw new NotFoundException("RoleManager not resolved. Check Identity registration.") + { + MessageKey = "Identity.RoleManagerNotResolved", + ResourceSource = typeof(IdentityResources), + }; if (roleManager.Roles is null) - throw new NotFoundException("Role store not configured. Ensure .AddRoles() and EF stores."); + throw new NotFoundException("Role store not configured. Ensure .AddRoles() and EF stores.") + { + MessageKey = "Identity.RoleStoreNotConfigured", + ResourceSource = typeof(IdentityResources), + }; var page = Math.Max(1, pageNumber); var size = Math.Clamp(pageSize, 1, 200); @@ -97,7 +106,11 @@ public async Task> GetRolesAsync( { FshRole? role = await roleManager.FindByIdAsync(id); - _ = role ?? throw new NotFoundException("role not found"); + _ = role ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; return new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }; } @@ -111,9 +124,9 @@ public async Task CreateOrUpdateRoleAsync(string roleId, string name, s if (role != null) { // System roles cannot be modified — neither renamed nor re-described. - EnsureNotSystemRole(role.Name, "System roles cannot be modified."); + EnsureNotSystemRole(role.Name, "System roles cannot be modified.", "Identity.SystemRoleCannotBeModified"); // And no custom role can be renamed to a system role's name. - EnsureNotSystemRole(name, "Cannot rename a role to a system role's name."); + EnsureNotSystemRole(name, "Cannot rename a role to a system role's name.", "Identity.CannotRenameToSystemRole"); role.Name = name; role.Description = description; @@ -122,7 +135,7 @@ public async Task CreateOrUpdateRoleAsync(string roleId, string name, s else { // No new role can be created using a system role's name. - EnsureNotSystemRole(name, "Cannot create a role using a system role's name."); + EnsureNotSystemRole(name, "Cannot create a role using a system role's name.", "Identity.CannotCreateWithSystemRoleName"); role = new FshRole(name, description); await roleManager.CreateAsync(role); @@ -135,9 +148,13 @@ public async Task DeleteRoleAsync(string id, CancellationToken cancellationToken { FshRole? role = await roleManager.FindByIdAsync(id); - _ = role ?? throw new NotFoundException("role not found"); + _ = role ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; - EnsureNotSystemRole(role.Name, "System roles cannot be deleted."); + EnsureNotSystemRole(role.Name, "System roles cannot be deleted.", "Identity.SystemRolesCannotBeDeleted"); // Snapshot affected users BEFORE the cascade removes the role-mapping rows, // otherwise the lookup returns an empty set after delete. @@ -149,7 +166,11 @@ public async Task DeleteRoleAsync(string id, CancellationToken cancellationToken public async Task GetWithPermissionsAsync(string id, CancellationToken cancellationToken = default) { var role = await GetRoleAsync(id, cancellationToken); - _ = role ?? throw new NotFoundException("role not found"); + _ = role ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; role.Permissions = await context.RoleClaims .AsNoTracking() @@ -165,9 +186,13 @@ public async Task UpdatePermissionsAsync(string roleId, List per ArgumentNullException.ThrowIfNull(permissions); var role = await roleManager.FindByIdAsync(roleId) - ?? throw new NotFoundException("role not found"); + ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; - EnsureNotSystemRole(role.Name, "System role permissions are managed by the framework and cannot be modified."); + EnsureNotSystemRole(role.Name, "System role permissions are managed by the framework and cannot be modified.", "Identity.SystemRolePermissionsManaged"); FilterRootPermissions(permissions); var currentClaims = await roleManager.GetClaimsAsync(role); @@ -181,11 +206,15 @@ public async Task UpdatePermissionsAsync(string roleId, List per return "permissions updated"; } - private static void EnsureNotSystemRole(string? roleName, string message) + private static void EnsureNotSystemRole(string? roleName, string message, string messageKey) { if (!string.IsNullOrEmpty(roleName) && RoleConstants.IsDefault(roleName)) { - throw new CustomException(message, Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException(message, Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = messageKey, + ResourceSource = typeof(IdentityResources), + }; } } @@ -214,7 +243,11 @@ private async Task RemoveRevokedPermissionsAsync(FshRole role, IList error.Description).ToList(); - throw new CustomException("operation failed", errors); + throw new CustomException("operation failed", errors) + { + MessageKey = "Identity.OperationFailed", + ResourceSource = typeof(IdentityResources), + }; } } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs index e206420a88..94f45bd2c2 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs @@ -1,12 +1,14 @@ -using FluentValidation; +using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.UpsertRole; public sealed class UpsertRoleCommandValidator : AbstractValidator { - public UpsertRoleCommandValidator() + public UpsertRoleCommandValidator(IStringLocalizer localizer) { - RuleFor(x => x.Name).NotEmpty().WithMessage("Role name is required."); + RuleFor(x => x.Name).NotEmpty().WithMessage(_ => localizer["Validation.RoleNameRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs index f9b8449cb6..3e642608fb 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeAllSessions; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions; public sealed class AdminRevokeAllSessionsCommandValidator : AbstractValidator { - public AdminRevokeAllSessionsCommandValidator() + public AdminRevokeAllSessionsCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.Reason) - .MaximumLength(500).WithMessage("Reason must not exceed 500 characters.") + .MaximumLength(500).WithMessage(_ => localizer["Validation.ReasonMaxLength"]) .When(x => x.Reason is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs index 8e59cb4ec9..f4edf82bbf 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs @@ -1,20 +1,22 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeSession; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession; public sealed class AdminRevokeSessionCommandValidator : AbstractValidator { - public AdminRevokeSessionCommandValidator() + public AdminRevokeSessionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.SessionId) - .NotEmpty().WithMessage("Session ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.SessionIdRequired"]); RuleFor(x => x.Reason) - .MaximumLength(500).WithMessage("Reason must not exceed 500 characters.") + .MaximumLength(500).WithMessage(_ => localizer["Validation.ReasonMaxLength"]) .When(x => x.Reason is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs index 06b3786ba8..95bbf174d7 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.GetTenantSessions; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.GetTenantSessions; public sealed class GetTenantSessionsValidator : AbstractValidator { - public GetTenantSessionsValidator() + public GetTenantSessionsValidator(IStringLocalizer localizer) { RuleFor(x => x.PageNumber) - .GreaterThanOrEqualTo(1).WithMessage("Page number must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(x => x.PageSize) - .GreaterThanOrEqualTo(1).WithMessage("Page size must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageSizeMinimum"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs index c0ceb12b33..d5e79ba14d 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.RevokeSession; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeSession; public sealed class RevokeSessionCommandValidator : AbstractValidator { - public RevokeSessionCommandValidator() + public RevokeSessionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.SessionId) - .NotEmpty().WithMessage("Session ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.SessionIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs index dd66630e31..543fe28a71 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Framework.Core.Exceptions; using FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.IdentityModel.Tokens.Jwt; @@ -51,7 +52,11 @@ public async ValueTask Handle( if (validated is null) { await _securityAudit.TokenRevokedAsync("unknown", clientId!, "InvalidRefreshToken", cancellationToken); - throw new UnauthorizedException("Invalid refresh token."); + throw new UnauthorizedException("Invalid refresh token.") + { + MessageKey = "Identity.InvalidRefreshToken", + ResourceSource = typeof(IdentityResources), + }; } var (subject, claims) = validated.Value; @@ -62,7 +67,11 @@ public async ValueTask Handle( if (!isSessionValid) { await _securityAudit.TokenRevokedAsync(subject, clientId!, "SessionRevoked", cancellationToken); - throw new UnauthorizedException("Session has been revoked."); + throw new UnauthorizedException("Session has been revoked.") + { + MessageKey = "Identity.SessionRevoked", + ResourceSource = typeof(IdentityResources), + }; } // Optionally, cross-check the provided access token subject @@ -87,7 +96,11 @@ public async ValueTask Handle( !string.Equals(accessTokenSubject, subject, StringComparison.Ordinal)) { await _securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenSubjectMismatch", cancellationToken); - throw new UnauthorizedException("Access token subject mismatch."); + throw new UnauthorizedException("Access token subject mismatch.") + { + MessageKey = "Identity.AccessTokenSubjectMismatch", + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs index 24bffb7030..3b90223383 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs @@ -1,5 +1,6 @@ using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; using FSH.Framework.Eventing.Outbox; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Auditing.Contracts; @@ -7,6 +8,7 @@ using FSH.Modules.Identity.Contracts.Events; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.Security.Claims; @@ -70,7 +72,11 @@ await _securityAudit.LoginFailedAsync( ip: ip, ct: cancellationToken); - throw new UnauthorizedAccessException("Invalid credentials."); + throw new LocalizedUnauthorizedAccessException("Invalid credentials.") + { + MessageKey = "Identity.InvalidCredentials", + ResourceSource = typeof(IdentityResources), + }; } // Unpack subject + claims diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs index 4d7bd0dd67..11601944e0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; @@ -8,6 +9,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; using System.ComponentModel; namespace FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration; @@ -35,14 +37,15 @@ [AllowAnonymous] async Task, UnauthorizedHttpResult, P [DefaultValue("root")][FromHeader] string tenant, [FromHeader(Name = AppHeader)] string? app, [FromServices] IMediator mediator, + [FromServices] IStringLocalizer localizer, CancellationToken ct) => { if (IsRootViaDashboard(tenant, app)) { return TypedResults.Problem( statusCode: StatusCodes.Status403Forbidden, - title: "App boundary", - detail: "SuperAdmin accounts must use the admin app. Sign in there instead of the tenant dashboard."); + title: localizer["Error.AppBoundary"], + detail: localizer["Error.AppBoundary.Detail"]); } var token = await mediator.Send(command, ct); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs index fa7c7ff9b4..0f000f2fcd 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Identity.Contracts.v1.TwoFactor; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.AspNetCore.Identity; @@ -31,13 +32,22 @@ public async ValueTask Handle( var userId = _currentUser.GetUserId().ToString(); var user = await _userManager.FindByIdAsync(userId) - ?? throw new NotFoundException($"User {userId} not found."); + ?? throw new NotFoundException($"User {userId} not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; // Require current password so a stolen access token alone can't downgrade // account security. if (!await _userManager.CheckPasswordAsync(user, command.CurrentPassword)) { - throw new UnauthorizedException("Current password is incorrect."); + throw new UnauthorizedException("Current password is incorrect.") + { + MessageKey = "Identity.CurrentPasswordIncorrect", + ResourceSource = typeof(IdentityResources), + }; } await _userManager.SetTwoFactorEnabledAsync(user, false); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs index 2a95e8678c..39ccfdf2f0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.TwoFactor; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.AspNetCore.Identity; @@ -35,13 +36,22 @@ public async ValueTask Handle( var userId = _currentUser.GetUserId().ToString(); var user = await _userManager.FindByIdAsync(userId) - ?? throw new NotFoundException($"User {userId} not found."); + ?? throw new NotFoundException($"User {userId} not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; // Always reset so calling enroll twice rotates the secret — prevents stale codes // from a prior incomplete enrollment from silently succeeding. await _userManager.ResetAuthenticatorKeyAsync(user); var sharedKey = await _userManager.GetAuthenticatorKeyAsync(user) - ?? throw new CustomException("Failed to generate authenticator key."); + ?? throw new CustomException("Failed to generate authenticator key.") + { + MessageKey = "Identity.FailedToGenerateAuthenticatorKey", + ResourceSource = typeof(IdentityResources), + }; var email = user.Email ?? user.UserName ?? user.Id; var authenticatorUri = string.Format( diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs index edc6a52ac9..151ed497c6 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Identity.Contracts.v1.TwoFactor; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.AspNetCore.Identity; @@ -31,7 +32,12 @@ public async ValueTask Handle( var userId = _currentUser.GetUserId().ToString(); var user = await _userManager.FindByIdAsync(userId) - ?? throw new NotFoundException($"User {userId} not found."); + ?? throw new NotFoundException($"User {userId} not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; var sanitized = command.Code.Replace(" ", string.Empty, StringComparison.Ordinal); var valid = await _userManager.VerifyTwoFactorTokenAsync( @@ -44,7 +50,11 @@ public async ValueTask Handle( throw new CustomException( "The authenticator code is invalid.", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.AuthenticatorCodeInvalid", + ResourceSource = typeof(IdentityResources), + }; } await _userManager.SetTwoFactorEnabledAsync(user, true); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs index 71523b2f76..2c606b98b3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.AdminConfirmEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.AdminConfirmEmail; public sealed class AdminConfirmEmailCommandValidator : AbstractValidator { - public AdminConfirmEmailCommandValidator() + public AdminConfirmEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs index 703d43d398..500c4242b4 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.AssignUserRoles; public sealed class AssignUserRolesCommandValidator : AbstractValidator { - public AssignUserRolesCommandValidator() + public AssignUserRolesCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.UserRoles) - .NotNull().WithMessage("User roles list is required."); + .NotNull().WithMessage(_ => localizer["Validation.UserRolesRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs index 0581ae1878..6563029bb8 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs @@ -1,7 +1,9 @@ using FluentValidation; using FSH.Framework.Core.Context; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ChangePassword; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ChangePassword; @@ -12,26 +14,27 @@ public sealed class ChangePasswordValidator : AbstractValidator localizer) { _passwordHistoryService = passwordHistoryService; _currentUser = currentUser; RuleFor(p => p.Password) .NotEmpty() - .WithMessage("Current password is required."); + .WithMessage(_ => localizer["Validation.CurrentPasswordRequired"]); RuleFor(p => p.NewPassword) .NotEmpty() - .WithMessage("New password is required.") + .WithMessage(_ => localizer["Validation.NewPasswordRequired"]) .NotEqual(p => p.Password) - .WithMessage("New password must be different from the current password.") + .WithMessage(_ => localizer["Validation.NewPasswordMustDiffer"]) .MustAsync(NotBeInPasswordHistoryAsync) - .WithMessage("This password has been used recently. Please choose a different password."); + .WithMessage(_ => localizer["Validation.PasswordRecentlyUsed"]); RuleFor(p => p.ConfirmNewPassword) .Equal(p => p.NewPassword) - .WithMessage("Passwords do not match."); + .WithMessage(_ => localizer["Validation.PasswordsDoNotMatch"]); } private async Task NotBeInPasswordHistoryAsync(string newPassword, CancellationToken cancellationToken) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs index 54805a491b..d8b599e9fe 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs @@ -1,19 +1,21 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ConfirmEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ConfirmEmail; public sealed class ConfirmEmailCommandValidator : AbstractValidator { - public ConfirmEmailCommandValidator() + public ConfirmEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.Code) - .NotEmpty().WithMessage("Confirmation code is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.ConfirmationCodeRequired"]); RuleFor(x => x.Tenant) - .NotEmpty().WithMessage("Tenant is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.TenantRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs index f5410d84ac..f73908b372 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.DeleteUser; public sealed class DeleteUserCommandValidator : AbstractValidator { - public DeleteUserCommandValidator() + public DeleteUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs index a7d6704751..1d833cf798 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Users.GetUserGroups; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -25,7 +26,12 @@ public async ValueTask> Handle(GetUserGroupsQuery query, C if (!userExists) { - throw new NotFoundException($"User with ID '{query.UserId}' not found."); + throw new NotFoundException($"User with ID '{query.UserId}' not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [query.UserId], + ResourceSource = typeof(IdentityResources), + }; } // Get user's groups diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs index 54d774b2da..f52db83b99 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs @@ -1,39 +1,41 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.RegisterUser; public sealed class RegisterUserCommandValidator : AbstractValidator { - public RegisterUserCommandValidator() + public RegisterUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.FirstName) - .NotEmpty().WithMessage("First name is required.") - .MaximumLength(100).WithMessage("First name must not exceed 100 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.FirstNameRequired"]) + .MaximumLength(100).WithMessage(_ => localizer["Validation.FirstNameMaxLength"]); RuleFor(x => x.LastName) - .NotEmpty().WithMessage("Last name is required.") - .MaximumLength(100).WithMessage("Last name must not exceed 100 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.LastNameRequired"]) + .MaximumLength(100).WithMessage(_ => localizer["Validation.LastNameMaxLength"]); RuleFor(x => x.Email) - .NotEmpty().WithMessage("Email is required.") - .EmailAddress().WithMessage("A valid email address is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.EmailRequired"]) + .EmailAddress().WithMessage(_ => localizer["Validation.EmailInvalid"]); RuleFor(x => x.UserName) - .NotEmpty().WithMessage("Username is required.") - .MinimumLength(3).WithMessage("Username must be at least 3 characters.") - .MaximumLength(50).WithMessage("Username must not exceed 50 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.UsernameRequired"]) + .MinimumLength(3).WithMessage(_ => localizer["Validation.UsernameMinLength"]) + .MaximumLength(50).WithMessage(_ => localizer["Validation.UsernameMaxLength"]); RuleFor(x => x.Password) - .NotEmpty().WithMessage("Password is required.") - .MinimumLength(6).WithMessage("Password must be at least 6 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.PasswordRequired"]) + .MinimumLength(6).WithMessage(_ => localizer["Validation.PasswordMinLength"]); RuleFor(x => x.ConfirmPassword) - .NotEmpty().WithMessage("Password confirmation is required.") - .Equal(x => x.Password).WithMessage("Passwords do not match."); + .NotEmpty().WithMessage(_ => localizer["Validation.PasswordConfirmationRequired"]) + .Equal(x => x.Password).WithMessage(_ => localizer["Validation.PasswordsDoNotMatch"]); RuleFor(x => x.PhoneNumber) - .MaximumLength(20).WithMessage("Phone number must not exceed 20 characters.") + .MaximumLength(20).WithMessage(_ => localizer["Validation.PhoneNumberMaxLength"]) .When(x => x.PhoneNumber is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs index 34d29f7b1b..28cd866e23 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ResendConfirmationEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ResendConfirmationEmail; public sealed class ResendConfirmationEmailCommandValidator : AbstractValidator { - public ResendConfirmationEmailCommandValidator() + public ResendConfirmationEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs index 7069e6f653..a5328bc3d5 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs @@ -1,14 +1,16 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Web.Validation; using FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.SearchUsers; public sealed class SearchUsersQueryValidator : AbstractValidator { - public SearchUsersQueryValidator() + public SearchUsersQueryValidator(IStringLocalizer localizer) { - Include(new PagedQueryValidator()); + Include(new PagedQueryValidator(localizer)); RuleFor(q => q.Search) .MaximumLength(200) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs index 236b76e778..36a9fc6304 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs @@ -18,7 +18,10 @@ public async ValueTask Handle(SetProfileImageCommand command, Cancellation var userId = currentUser.GetUserId(); if (userId == Guid.Empty) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; } await profileService diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs index 4eece88de2..2df3f36a54 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ToggleUserStatus; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus; public sealed class ToggleUserStatusCommandValidator : AbstractValidator { - public ToggleUserStatusCommandValidator() + public ToggleUserStatusCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs index 2fa5ff12d3..cb60d2af57 100644 --- a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Identity.Claims; using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Localization; using System.Security.Claims; namespace FSH.Modules.Identity.Services; @@ -42,7 +43,11 @@ public void SetCurrentUser(ClaimsPrincipal user) { if (_user != null) { - throw new CustomException("Method reserved for in-scope initialization"); + throw new CustomException("Method reserved for in-scope initialization") + { + MessageKey = "Identity.InScopeInitializationOnly", + ResourceSource = typeof(IdentityResources), + }; } _user = user; @@ -52,7 +57,11 @@ public void SetCurrentUserId(string userId) { if (_userId != Guid.Empty) { - throw new CustomException("Method reserved for in-scope initialization"); + throw new CustomException("Method reserved for in-scope initialization") + { + MessageKey = "Identity.InScopeInitializationOnly", + ResourceSource = typeof(IdentityResources), + }; } if (!string.IsNullOrEmpty(userId)) diff --git a/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs b/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs index 6ed5ebef65..56cf011147 100644 --- a/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.v1.Impersonation; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Hybrid; @@ -82,7 +83,11 @@ public async Task RevokeAsync( var grant = await db.ImpersonationGrants .FirstOrDefaultAsync(g => g.Id == id, ct) .ConfigureAwait(false) - ?? throw new NotFoundException("impersonation grant not found"); + ?? throw new NotFoundException("impersonation grant not found") + { + MessageKey = "Identity.ImpersonationGrantNotFound", + ResourceSource = typeof(IdentityResources), + }; if (grant.IsTerminal) { diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs index 1f41f867a6..e194d9b5ca 100644 --- a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs @@ -6,6 +6,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using UAParser; @@ -40,7 +41,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(_multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("Invalid tenant"); + throw new UnauthorizedException("Invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } @@ -88,7 +92,11 @@ public async Task> GetUserSessionsAsync( var currentUserId = _currentUser.GetUserId().ToString(); if (!string.Equals(userId, currentUserId, StringComparison.OrdinalIgnoreCase)) { - throw new UnauthorizedAccessException("Cannot view sessions for another user"); + throw new LocalizedUnauthorizedAccessException("Cannot view sessions for another user") + { + MessageKey = "Identity.CannotViewOthersSessions", + ResourceSource = typeof(IdentityResources), + }; } var now = _timeProvider.GetUtcNow().UtcDateTime; @@ -196,7 +204,11 @@ public async Task RevokeSessionAsync( var currentUserId = _currentUser.GetUserId().ToString(); if (!string.Equals(session.UserId, currentUserId, StringComparison.OrdinalIgnoreCase)) { - throw new UnauthorizedAccessException("Cannot revoke session for another user"); + throw new LocalizedUnauthorizedAccessException("Cannot revoke session for another user") + { + MessageKey = "Identity.CannotRevokeOthersSession", + ResourceSource = typeof(IdentityResources), + }; } var tenantId = _multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; @@ -224,7 +236,11 @@ public async Task RevokeAllSessionsAsync( var currentUserId = _currentUser.GetUserId().ToString(); if (!string.Equals(userId, currentUserId, StringComparison.OrdinalIgnoreCase)) { - throw new UnauthorizedAccessException("Cannot revoke sessions for another user"); + throw new LocalizedUnauthorizedAccessException("Cannot revoke sessions for another user") + { + MessageKey = "Identity.CannotRevokeOthersSessions", + ResourceSource = typeof(IdentityResources), + }; } var query = _db.UserSessions diff --git a/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs index f29a3eb8fd..b0bfbb5795 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs @@ -7,6 +7,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using System.Collections.ObjectModel; @@ -66,7 +67,11 @@ public async Task ResetPasswordAsync(string email, string password, string token var user = await userManager.FindByEmailAsync(email); if (user == null) { - throw new NotFoundException("user not found"); + throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; } token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)); @@ -75,7 +80,11 @@ public async Task ResetPasswordAsync(string email, string password, string token if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - throw new CustomException("error resetting password", errors); + throw new CustomException("error resetting password", errors) + { + MessageKey = "Identity.ErrorResettingPassword", + ResourceSource = typeof(IdentityResources), + }; } // Raise domain event for password reset @@ -88,14 +97,22 @@ public async Task ChangePasswordAsync(string password, string newPassword, strin { var user = await userManager.FindByIdAsync(userId); - _ = user ?? throw new NotFoundException("user not found"); + _ = user ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; var result = await userManager.ChangePasswordAsync(user, password, newPassword); if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - throw new CustomException("failed to change password", errors); + throw new CustomException("failed to change password", errors) + { + MessageKey = "Identity.FailedToChangePassword", + ResourceSource = typeof(IdentityResources), + }; } // Raise domain event for password change @@ -114,7 +131,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs index 79409e4379..ad2dff8911 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs @@ -11,6 +11,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; @@ -79,14 +80,23 @@ public async Task ConfirmEmailAsync(string userId, string code, string t .Where(u => u.Id == userId && !u.EmailConfirmed) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new CustomException("An error occurred while confirming E-Mail."); + _ = user ?? throw new CustomException("An error occurred while confirming E-Mail.") + { + MessageKey = "Identity.EmailConfirmationError", + ResourceSource = typeof(IdentityResources), + }; code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); var result = await userManager.ConfirmEmailAsync(user, code); return result.Succeeded ? string.Format(CultureInfo.InvariantCulture, "Account Confirmed for E-Mail {0}. You can now use the /api/tokens endpoint to generate JWT.", user.Email) - : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email)); + : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email)) + { + MessageKey = "Identity.EmailConfirmationFailedFor", + MessageArgs = [user.Email!], + ResourceSource = typeof(IdentityResources), + }; } public async Task AdminConfirmEmailAsync(string userId, CancellationToken cancellationToken = default) @@ -96,7 +106,12 @@ public async Task AdminConfirmEmailAsync(string userId, CancellationToken cancel var user = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException($"User {userId} was not found."); + ?? throw new NotFoundException($"User {userId} was not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; // Idempotent: a second confirm is a no-op rather than an error. if (user.EmailConfirmed) @@ -112,7 +127,12 @@ public async Task AdminConfirmEmailAsync(string userId, CancellationToken cancel CultureInfo.InvariantCulture, "An error occurred while confirming the email for {0}: {1}", user.Email, - string.Join("; ", result.Errors.Select(e => e.Description)))); + string.Join("; ", result.Errors.Select(e => e.Description)))) + { + MessageKey = "Identity.EmailConfirmationFailedWithErrors", + MessageArgs = [user.Email!, string.Join("; ", result.Errors.Select(e => e.Description))], + ResourceSource = typeof(IdentityResources), + }; } } @@ -123,14 +143,24 @@ public async Task ResendConfirmationEmailAsync(string userId, string origin, Can var user = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException($"User {userId} was not found."); + ?? throw new NotFoundException($"User {userId} was not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; if (user.EmailConfirmed) { throw new CustomException(string.Format( CultureInfo.InvariantCulture, "The email for {0} is already confirmed.", - user.Email)); + user.Email)) + { + MessageKey = "Identity.EmailAlreadyConfirmed", + MessageArgs = [user.Email!], + ResourceSource = typeof(IdentityResources), + }; } await SendConfirmationEmailAsync(user, origin, cancellationToken); @@ -144,21 +174,33 @@ public async Task ConfirmPhoneNumberAsync(string userId, string code, Ca .Where(u => u.Id == userId && !u.PhoneNumberConfirmed) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new CustomException("An error occurred while confirming phone number."); + _ = user ?? throw new CustomException("An error occurred while confirming phone number.") + { + MessageKey = "Identity.PhoneConfirmationError", + ResourceSource = typeof(IdentityResources), + }; code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); var result = await userManager.ChangePhoneNumberAsync(user, user.PhoneNumber!, code); return result.Succeeded ? string.Format(CultureInfo.InvariantCulture, "Phone number {0} confirmed successfully.", user.PhoneNumber) - : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber)); + : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber)) + { + MessageKey = "Identity.PhoneConfirmationFailedFor", + MessageArgs = [user.PhoneNumber!], + ResourceSource = typeof(IdentityResources), + }; } private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } @@ -166,7 +208,11 @@ private static string ExtractEmailFromPrincipal(ClaimsPrincipal principal) { return principal.FindFirstValue(ClaimTypes.Email) ?? principal.FindFirstValue("email") - ?? throw new CustomException("Email claim is required for external authentication."); + ?? throw new CustomException("Email claim is required for external authentication.") + { + MessageKey = "Identity.EmailClaimRequired", + ResourceSource = typeof(IdentityResources), + }; } private async Task CreateUserFromPrincipalAsync(ClaimsPrincipal principal, string email) @@ -193,7 +239,11 @@ private async Task CreateUserFromPrincipalAsync(ClaimsPrincipal princip throw new CustomException( "Failed to create user from external principal.", errors, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.FailedToCreateUserFromPrincipal", + ResourceSource = typeof(IdentityResources), + }; } return user; @@ -233,7 +283,11 @@ private static void ValidatePasswordMatch(string password, string confirmPasswor throw new CustomException( "Passwords do not match.", errors: null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.PasswordsDoNotMatch", + ResourceSource = typeof(IdentityResources), + }; } } @@ -267,7 +321,11 @@ private async Task CreateUserWithPasswordAsync( throw new CustomException( "Unable to register the user.", errors, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.UnableToRegisterUser", + ResourceSource = typeof(IdentityResources), + }; } return user; diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs index 52f55a60a3..4cab8f3255 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs @@ -8,6 +8,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -26,7 +27,11 @@ public async Task AssignRolesAsync(string userId, List user var user = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException("user not found"); + ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; await ValidateAdminRoleChangeAsync(user, userRoles); @@ -44,10 +49,18 @@ public async Task AssignRolesAsync(string userId, List user public async Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId) - ?? throw new NotFoundException("user not found"); + ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken) - ?? throw new NotFoundException("roles not found"); + ?? throw new NotFoundException("roles not found") + { + MessageKey = "Identity.RolesNotFound", + ResourceSource = typeof(IdentityResources), + }; // Single membership query instead of one IsInRoleAsync round-trip per role. var memberships = await userManager.GetRolesAsync(user); @@ -90,13 +103,21 @@ private async Task ValidateAdminRoleChangeAsync(FshUser user, List throw new CustomException( "Administrators cannot remove their own admin role.", Array.Empty(), - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.AdminCannotRemoveOwnRole", + ResourceSource = typeof(IdentityResources), + }; } // The root tenant's seed admin is the framework's last-resort recovery account. if (IsRootTenantAdmin(user)) { - throw new ForbiddenException("The root tenant administrator cannot be demoted."); + throw new ForbiddenException("The root tenant administrator cannot be demoted.") + { + MessageKey = "Identity.RootAdminCannotBeDemoted", + ResourceSource = typeof(IdentityResources), + }; } // After this removal, at least one admin must remain in the tenant — matches @@ -118,7 +139,11 @@ private async Task EnsureMinimumAdminCountAsync() throw new CustomException( "Tenant must retain at least one administrator.", Array.Empty(), - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.TenantMustRetainOneAdmin", + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs index f0e12c4829..28666a248f 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs @@ -7,6 +7,7 @@ using FSH.Modules.Auditing.Contracts; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -40,7 +41,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } @@ -52,16 +56,26 @@ private async Task BuildToggleContextAsync( var actorId = currentUser.GetUserId(); if (actorId == Guid.Empty) { - throw new UnauthorizedException("authenticated user required to toggle status"); + throw new UnauthorizedException("authenticated user required to toggle status") + { + MessageKey = "Error.NoCurrentUser", + }; } var actor = await userManager.FindByIdAsync(actorId.ToString()) - ?? throw new UnauthorizedException("current user not found"); + ?? throw new UnauthorizedException("current user not found") + { + MessageKey = "Error.NoCurrentUser", + }; var targetUser = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException("User Not Found."); + ?? throw new NotFoundException("User Not Found.") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; return new ToggleStatusContext( ActorId: actorId, @@ -78,19 +92,31 @@ private async Task ValidateTogglePermissionsAsync( if (!await userManager.IsInRoleAsync(context.Actor, RoleConstants.Admin)) { await AuditPolicyFailureAsync(context, "ActorNotAdmin", cancellationToken); - throw new ForbiddenException("Only administrators can change user status."); + throw new ForbiddenException("Only administrators can change user status.") + { + MessageKey = "Identity.OnlyAdminsCanChangeStatus", + ResourceSource = typeof(IdentityResources), + }; } if (!context.ActivateUser && context.ActorId.ToString() == context.TargetUser.Id) { await AuditPolicyFailureAsync(context, "SelfDeactivationBlocked", cancellationToken); - throw new CustomException("Users cannot deactivate themselves.", Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException("Users cannot deactivate themselves.", Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.CannotDeactivateSelf", + ResourceSource = typeof(IdentityResources), + }; } if (!context.ActivateUser && await userManager.IsInRoleAsync(context.TargetUser, RoleConstants.Admin)) { await AuditPolicyFailureAsync(context, "AdminDeactivationBlocked", cancellationToken); - throw new CustomException("Administrators cannot be deactivated.", Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException("Administrators cannot be deactivated.", Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.AdminsCannotBeDeactivated", + ResourceSource = typeof(IdentityResources), + }; } if (!context.ActivateUser) @@ -107,7 +133,11 @@ private async Task EnsureMinimumActiveAdminsAsync( if (!activeAdmins.Any(u => u.IsActive)) { await AuditPolicyFailureAsync(context, "NoActiveAdmins", cancellationToken); - throw new CustomException("Tenant must have at least one active administrator.", Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException("Tenant must have at least one active administrator.", Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.TenantMustHaveActiveAdmin", + ResourceSource = typeof(IdentityResources), + }; } } @@ -130,7 +160,11 @@ private async Task SaveAndAuditAsync( var result = await userManager.UpdateAsync(context.TargetUser); if (!result.Succeeded) { - throw new CustomException("Toggle status failed", result.Errors.Select(e => e.Description).ToList(), HttpStatusCode.BadRequest); + throw new CustomException("Toggle status failed", result.Errors.Select(e => e.Description).ToList(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.ToggleStatusFailed", + ResourceSource = typeof(IdentityResources), + }; } await auditClient.WriteActivityAsync( diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs index cf7d7a5282..13d4bad51a 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs @@ -1,12 +1,14 @@ using FluentValidation; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Contracts.v1.AdjustTenantValidity; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.AdjustTenantValidity; public sealed class AdjustTenantValidityCommandValidator : AbstractValidator { - public AdjustTenantValidityCommandValidator() + public AdjustTenantValidityCommandValidator(IStringLocalizer localizer) { RuleFor(t => t.TenantId).NotEmpty(); @@ -14,10 +16,10 @@ public AdjustTenantValidityCommandValidator() // Activate/Deactivate guards that already refuse the root tenant). RuleFor(t => t.TenantId) .Must(id => !string.Equals(id, MultitenancyConstants.Root.Id, StringComparison.Ordinal)) - .WithMessage("The root operator tenant's validity cannot be adjusted."); + .WithMessage(_ => localizer["Validation.RootTenantValidityImmutable"]); RuleFor(t => t.ValidUpto) .Must(d => d != default) - .WithMessage("A valid 'validUpto' date is required."); + .WithMessage(_ => localizer["Validation.ValidUptoRequired"]); } } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs index 2e199a329d..ba57a6f7b7 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs @@ -2,26 +2,28 @@ using FSH.Framework.Persistence; using FSH.Modules.Multitenancy.Contracts; using FSH.Modules.Multitenancy.Contracts.v1.CreateTenant; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.CreateTenant; public sealed class CreateTenantCommandValidator : AbstractValidator { - public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStringValidator connectionStringValidator) + public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStringValidator connectionStringValidator, IStringLocalizer localizer) { RuleFor(t => t.Id).Cascade(CascadeMode.Stop) .NotEmpty() .MustAsync(async (id, ct) => !await tenantService.ExistsWithIdAsync(id, ct).ConfigureAwait(false)) - .WithMessage((_, id) => $"Tenant {id} already exists."); + .WithMessage((_, id) => localizer["Validation.TenantAlreadyExists", id]); RuleFor(t => t.Name).Cascade(CascadeMode.Stop) .NotEmpty() .MustAsync(async (name, ct) => !await tenantService.ExistsWithNameAsync(name!, ct).ConfigureAwait(false)) - .WithMessage((_, name) => $"Tenant {name} already exists."); + .WithMessage((_, name) => localizer["Validation.TenantAlreadyExists", name!]); RuleFor(t => t.ConnectionString).Cascade(CascadeMode.Stop) .Must((_, cs) => string.IsNullOrWhiteSpace(cs) || connectionStringValidator.TryValidate(cs)) - .WithMessage("Connection string invalid."); + .WithMessage(_ => localizer["Validation.ConnectionStringInvalid"]); RuleFor(t => t.AdminEmail).Cascade(CascadeMode.Stop) .NotEmpty() @@ -32,13 +34,13 @@ public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStr RuleFor(t => t.AdminPassword).Cascade(CascadeMode.Stop) .NotEmpty() .MinimumLength(8) - .WithMessage("Admin password must be at least 8 characters."); + .WithMessage(_ => localizer["Validation.AdminPasswordMinLength"]); // Optional — null/empty falls back to the configured default plan. When supplied it must be a // lowercase plan slug; existence is validated by GetPlanTerm in the handler. RuleFor(t => t.PlanKey) .Matches("^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$") .When(t => !string.IsNullOrWhiteSpace(t.PlanKey)) - .WithMessage("Plan key must be a lowercase slug (a-z, 0-9, hyphen)."); + .WithMessage(_ => localizer["Validation.PlanKeySlug"]); } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs index 1e1d159d12..5d6a694d85 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Web.Validation; using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.GetTenants; public sealed class GetTenantsQueryValidator : AbstractValidator { - public GetTenantsQueryValidator() + public GetTenantsQueryValidator(IStringLocalizer localizer) { - Include(new PagedQueryValidator()); + Include(new PagedQueryValidator(localizer)); } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs index 3ccec43cca..a17367b3ef 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; using FSH.Modules.Multitenancy.Contracts.v1.RenewTenant; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.RenewTenant; public sealed class RenewTenantCommandValidator : AbstractValidator { - public RenewTenantCommandValidator() + public RenewTenantCommandValidator(IStringLocalizer localizer) { RuleFor(t => t.TenantId).NotEmpty(); RuleFor(t => t.PlanKey) .Matches("^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$") .When(t => !string.IsNullOrWhiteSpace(t.PlanKey)) - .WithMessage("Plan key must be a lowercase slug (a-z, 0-9, hyphen)."); + .WithMessage(_ => localizer["Validation.PlanKeySlug"]); } } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs index 889d447f56..534dc74178 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs @@ -1,33 +1,35 @@ using FluentValidation; using FSH.Modules.Multitenancy.Contracts.Dtos; using FSH.Modules.Multitenancy.Contracts.v1.UpdateTenantTheme; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; using System.Text.RegularExpressions; namespace FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme; public partial class UpdateTenantThemeCommandValidator : AbstractValidator { - public UpdateTenantThemeCommandValidator() + public UpdateTenantThemeCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Theme) .NotNull() - .WithMessage("Theme is required."); + .WithMessage(_ => localizer["Validation.ThemeRequired"]); RuleFor(x => x.Theme.LightPalette) .NotNull() - .SetValidator(new PaletteValidator()); + .SetValidator(new PaletteValidator(localizer)); RuleFor(x => x.Theme.DarkPalette) .NotNull() - .SetValidator(new PaletteValidator()); + .SetValidator(new PaletteValidator(localizer)); RuleFor(x => x.Theme.Typography) .NotNull() - .SetValidator(new TypographyValidator()); + .SetValidator(new TypographyValidator(localizer)); RuleFor(x => x.Theme.Layout) .NotNull() - .SetValidator(new LayoutValidator()); + .SetValidator(new LayoutValidator(localizer)); } [GeneratedRegex("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$")] @@ -35,17 +37,17 @@ public UpdateTenantThemeCommandValidator() private sealed class PaletteValidator : AbstractValidator { - public PaletteValidator() + public PaletteValidator(IStringLocalizer localizer) { - RuleFor(x => x.Primary).Must(BeValidHexColor).WithMessage("Primary must be a valid hex color."); - RuleFor(x => x.Secondary).Must(BeValidHexColor).WithMessage("Secondary must be a valid hex color."); - RuleFor(x => x.Tertiary).Must(BeValidHexColor).WithMessage("Tertiary must be a valid hex color."); - RuleFor(x => x.Background).Must(BeValidHexColor).WithMessage("Background must be a valid hex color."); - RuleFor(x => x.Surface).Must(BeValidHexColor).WithMessage("Surface must be a valid hex color."); - RuleFor(x => x.Error).Must(BeValidHexColor).WithMessage("Error must be a valid hex color."); - RuleFor(x => x.Warning).Must(BeValidHexColor).WithMessage("Warning must be a valid hex color."); - RuleFor(x => x.Success).Must(BeValidHexColor).WithMessage("Success must be a valid hex color."); - RuleFor(x => x.Info).Must(BeValidHexColor).WithMessage("Info must be a valid hex color."); + RuleFor(x => x.Primary).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Primary"]); + RuleFor(x => x.Secondary).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Secondary"]); + RuleFor(x => x.Tertiary).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Tertiary"]); + RuleFor(x => x.Background).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Background"]); + RuleFor(x => x.Surface).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Surface"]); + RuleFor(x => x.Error).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Error"]); + RuleFor(x => x.Warning).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Warning"]); + RuleFor(x => x.Success).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Success"]); + RuleFor(x => x.Info).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Info"]); } private static bool BeValidHexColor(string color) => @@ -54,27 +56,27 @@ private static bool BeValidHexColor(string color) => private sealed class TypographyValidator : AbstractValidator { - public TypographyValidator() + public TypographyValidator(IStringLocalizer localizer) { RuleFor(x => x.FontFamily) .NotEmpty() .MaximumLength(200) .Must(BeValidFontFamily) - .WithMessage("FontFamily must be a valid web-safe font."); + .WithMessage(_ => localizer["Validation.FontFamilyWebSafe", "FontFamily"]); RuleFor(x => x.HeadingFontFamily) .NotEmpty() .MaximumLength(200) .Must(BeValidFontFamily) - .WithMessage("HeadingFontFamily must be a valid web-safe font."); + .WithMessage(_ => localizer["Validation.FontFamilyWebSafe", "HeadingFontFamily"]); RuleFor(x => x.FontSizeBase) .InclusiveBetween(10, 24) - .WithMessage("FontSizeBase must be between 10 and 24."); + .WithMessage(_ => localizer["Validation.FontSizeBaseRange"]); RuleFor(x => x.LineHeightBase) .InclusiveBetween(1.0, 2.5) - .WithMessage("LineHeightBase must be between 1.0 and 2.5."); + .WithMessage(_ => localizer["Validation.LineHeightBaseRange"]); } private static bool BeValidFontFamily(string fontFamily) => @@ -83,17 +85,17 @@ private static bool BeValidFontFamily(string fontFamily) => private sealed class LayoutValidator : AbstractValidator { - public LayoutValidator() + public LayoutValidator(IStringLocalizer localizer) { RuleFor(x => x.BorderRadius) .NotEmpty() .MaximumLength(20) .Matches(@"^\d+(px|rem|em|%)$") - .WithMessage("BorderRadius must be a valid CSS value (e.g., '4px', '0.5rem')."); + .WithMessage(_ => localizer["Validation.BorderRadiusInvalid"]); RuleFor(x => x.DefaultElevation) .InclusiveBetween(0, 24) - .WithMessage("DefaultElevation must be between 0 and 24."); + .WithMessage(_ => localizer["Validation.DefaultElevationRange"]); } } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.cs new file mode 100644 index 0000000000..74961366d1 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Multitenancy.Localization; + +/// Marker type binding IStringLocalizer<MultitenancyResources> to the Multitenancy resx catalog. +public sealed class MultitenancyResources; diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx new file mode 100644 index 0000000000..25df319c32 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Este tenant foi desativado. Entre em contato com seu administrador. + + + A assinatura deste tenant expirou. Renove para continuar. + + + Tenant {0} não encontrado durante o provisionamento. + + + Tenant {0} não encontrado para provisionamento. + + + O provisionamento já está em execução para o tenant {0}. + + + Provisionamento não encontrado para o tenant {0}. + + + O tenant {0} não está provisionado. Status: {1}. + + + Provisionamento {0} para o tenant {1} não encontrado. + + + o tenant {0} já está ativado + + + o tenant {0} já está desativado + + + É necessário pelo menos um tenant ativo. + + + O tenant raiz não pode ser desativado. + + + AppTenantInfo {0} não encontrado. + + + Apenas o tenant raiz pode definir o tema padrão. + + + Tema do tenant {0} não encontrado. + + + A validade do tenant operador raiz não pode ser ajustada. + + + Uma data 'validUpto' válida é obrigatória. + + + O tenant {0} já existe. + + + String de conexão inválida. + + + A senha do administrador deve ter pelo menos 8 caracteres. + + + A chave do plano deve ser um slug minúsculo (a-z, 0-9, hífen). + + + O tema é obrigatório. + + + {0} deve ser uma cor hexadecimal válida. + + + {0} deve ser uma fonte web-safe válida. + + + FontSizeBase deve estar entre 10 e 24. + + + LineHeightBase deve estar entre 1.0 e 2.5. + + + BorderRadius deve ser um valor CSS válido (ex.: '4px', '0.5rem'). + + + DefaultElevation deve estar entre 0 e 24. + + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx new file mode 100644 index 0000000000..fc4cf7d320 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + This tenant has been deactivated. Contact your administrator. + + + This tenant's subscription has expired. Please renew to continue. + + + Tenant {0} not found during provisioning. + + + Tenant {0} not found for provisioning. + + + Provisioning already running for tenant {0}. + + + Provisioning not found for tenant {0}. + + + Tenant {0} is not provisioned. Status: {1}. + + + Provisioning {0} for tenant {1} not found. + + + tenant {0} is already activated + + + tenant {0} is already deactivated + + + At least one active tenant is required. + + + The root tenant cannot be deactivated. + + + AppTenantInfo {0} Not Found. + + + Only the root tenant can set the default theme + + + Theme for tenant {0} not found + + + The root operator tenant's validity cannot be adjusted. + + + A valid 'validUpto' date is required. + + + Tenant {0} already exists. + + + Connection string invalid. + + + Admin password must be at least 8 characters. + + + Plan key must be a lowercase slug (a-z, 0-9, hyphen). + + + Theme is required. + + + {0} must be a valid hex color. + + + {0} must be a valid web-safe font. + + + FontSizeBase must be between 10 and 24. + + + LineHeightBase must be between 1.0 and 2.5. + + + BorderRadius must be a valid CSS value (e.g., '4px', '0.5rem'). + + + DefaultElevation must be between 0 and 24. + + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj b/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj index eb11887cb2..34ba1b60c0 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj @@ -2,7 +2,8 @@ FSH.Modules.Multitenancy FSH.Modules.Multitenancy - $(NoWarn);CA1031;CA1056;CA1008;CA1716;CA1812;S1135;S2139;S6667;S3267;S1172 + + $(NoWarn);CA1031;CA1056;CA1008;CA1716;CA1812;S1135;S2139;S6667;S3267;S1172;S2094 diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs index 698a90277c..f79a604756 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs @@ -26,6 +26,7 @@ using FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.RetryTenantProvisioning; using FSH.Modules.Multitenancy.Features.v1.RenewTenant; using FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme; +using FSH.Modules.Multitenancy.Localization; using FSH.Modules.Multitenancy.Provisioning; using FSH.Modules.Multitenancy.Services; using Hangfire; @@ -180,7 +181,11 @@ public void ConfigureMiddleware(IApplicationBuilder app) { if (!tenant.IsActive) { - throw new ForbiddenException("This tenant has been deactivated. Contact your administrator."); + throw new ForbiddenException("This tenant has been deactivated. Contact your administrator.") + { + MessageKey = "Multitenancy.TenantDeactivated", + ResourceSource = typeof(MultitenancyResources), + }; } // Expiry is enforced on every request (not just at login) with a grace period: @@ -191,7 +196,11 @@ public void ConfigureMiddleware(IApplicationBuilder app) var graceEndsUtc = tenant.ValidUpto.AddDays(graceDays); if (nowUtc > graceEndsUtc) { - throw new ForbiddenException("This tenant's subscription has expired. Please renew to continue."); + throw new ForbiddenException("This tenant's subscription has expired. Please renew to continue.") + { + MessageKey = "Multitenancy.TenantSubscriptionExpired", + ResourceSource = typeof(MultitenancyResources), + }; } // Inside the grace period: surface days-left so clients can warn. Set via OnStarting so diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs index 221125a3c4..0239b67cd5 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs @@ -4,6 +4,7 @@ using FSH.Framework.Persistence; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Localization; using FSH.Modules.Multitenancy.Services; using Microsoft.Extensions.Logging; @@ -34,7 +35,12 @@ public TenantProvisioningJob( public async Task RunAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default) { var tenant = await _tenantStore.GetAsync(tenantId).ConfigureAwait(false) - ?? throw new NotFoundException($"Tenant {tenantId} not found during provisioning."); + ?? throw new NotFoundException($"Tenant {tenantId} not found during provisioning.") + { + MessageKey = "Multitenancy.TenantNotFoundDuringProvisioning", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; var currentStep = TenantProvisioningStepName.Database; try diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs index c7a9ec719a..7a4ee5d9ea 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs @@ -4,6 +4,7 @@ using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Contracts.Dtos; using FSH.Modules.Multitenancy.Data; +using FSH.Modules.Multitenancy.Localization; using Hangfire; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -36,12 +37,22 @@ public TenantProvisioningService( public async Task StartAsync(string tenantId, CancellationToken cancellationToken) { var tenant = await _tenantStore.GetAsync(tenantId).ConfigureAwait(false) - ?? throw new NotFoundException($"Tenant {tenantId} not found for provisioning."); + ?? throw new NotFoundException($"Tenant {tenantId} not found for provisioning.") + { + MessageKey = "Multitenancy.TenantNotFoundForProvisioning", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; var existing = await GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false); if (existing is not null && (existing.Status is TenantProvisioningStatus.Running or TenantProvisioningStatus.Pending)) { - throw new CustomException($"Provisioning already running for tenant {tenantId}."); + throw new CustomException($"Provisioning already running for tenant {tenantId}.") + { + MessageKey = "Multitenancy.ProvisioningAlreadyRunning", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; } var correlationId = Guid.NewGuid().ToString(); @@ -85,7 +96,12 @@ public async Task StartAsync(string tenantId, CancellationTo public async Task GetStatusAsync(string tenantId, CancellationToken cancellationToken) { var provisioning = await GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Provisioning not found for tenant {tenantId}."); + ?? throw new NotFoundException($"Provisioning not found for tenant {tenantId}.") + { + MessageKey = "Multitenancy.ProvisioningNotFound", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; return ToDto(provisioning); } @@ -100,7 +116,12 @@ public async Task EnsureCanActivateAsync(string tenantId, CancellationToken canc if (provisioning.Status != TenantProvisioningStatus.Completed) { - throw new CustomException($"Tenant {tenantId} is not provisioned. Status: {provisioning.Status}."); + throw new CustomException($"Tenant {tenantId} is not provisioned. Status: {provisioning.Status}.") + { + MessageKey = "Multitenancy.TenantNotProvisioned", + MessageArgs = [tenantId, provisioning.Status], + ResourceSource = typeof(MultitenancyResources), + }; } } @@ -171,7 +192,12 @@ private async Task RequireAsync(string tenantId, string corr .Include(p => p.Steps) .FirstOrDefaultAsync(p => p.TenantId == tenantId && p.CorrelationId == correlationId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Provisioning {correlationId} for tenant {tenantId} not found."); + ?? throw new NotFoundException($"Provisioning {correlationId} for tenant {tenantId} not found.") + { + MessageKey = "Multitenancy.ProvisioningCorrelationNotFound", + MessageArgs = [correlationId, tenantId], + ResourceSource = typeof(MultitenancyResources), + }; } private static bool TryEnsureJobStorage() diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs index 15d8fdbe01..701b5c135d 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs @@ -9,6 +9,7 @@ using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; using FSH.Modules.Multitenancy.Data; using FSH.Modules.Multitenancy.Features.v1.GetTenants; +using FSH.Modules.Multitenancy.Localization; using FSH.Modules.Multitenancy.Provisioning; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -56,7 +57,12 @@ public async Task ActivateAsync(string id, CancellationToken cancellatio if (tenant.IsActive) { - throw new CustomException($"tenant {id} is already activated"); + throw new CustomException($"tenant {id} is already activated") + { + MessageKey = "Multitenancy.TenantAlreadyActivated", + MessageArgs = [id], + ResourceSource = typeof(MultitenancyResources), + }; } await _provisioningService.EnsureCanActivateAsync(id, cancellationToken).ConfigureAwait(false); @@ -123,18 +129,31 @@ public async Task DeactivateAsync(string id, CancellationToken cancellat var tenant = await GetTenantInfoAsync(id, cancellationToken).ConfigureAwait(false); if (!tenant.IsActive) { - throw new CustomException($"tenant {id} is already deactivated"); + throw new CustomException($"tenant {id} is already deactivated") + { + MessageKey = "Multitenancy.TenantAlreadyDeactivated", + MessageArgs = [id], + ResourceSource = typeof(MultitenancyResources), + }; } int tenantCount = (await _tenantStore.GetAllAsync().ConfigureAwait(false)).Count(t => t.IsActive); if (tenantCount <= 1) { - throw new CustomException("At least one active tenant is required."); + throw new CustomException("At least one active tenant is required.") + { + MessageKey = "Multitenancy.AtLeastOneActiveTenantRequired", + ResourceSource = typeof(MultitenancyResources), + }; } if (tenant.Id.Equals(MultitenancyConstants.Root.Id, StringComparison.OrdinalIgnoreCase)) { - throw new CustomException("The root tenant cannot be deactivated."); + throw new CustomException("The root tenant cannot be deactivated.") + { + MessageKey = "Multitenancy.RootTenantCannotBeDeactivated", + ResourceSource = typeof(MultitenancyResources), + }; } tenant.Deactivate(); @@ -247,7 +266,12 @@ public async Task AdjustValidityAsync(string id, DateTime validUpto, C private async Task GetTenantInfoAsync(string id, CancellationToken cancellationToken = default) => await _tenantStore.GetAsync(id).ConfigureAwait(false) - ?? throw new NotFoundException($"{typeof(AppTenantInfo).Name} {id} Not Found."); + ?? throw new NotFoundException($"{typeof(AppTenantInfo).Name} {id} Not Found.") + { + MessageKey = "Multitenancy.TenantNotFound", + MessageArgs = [id], + ResourceSource = typeof(MultitenancyResources), + }; // Finbuckle resolves via the distributed-cache store first (60-min TTL) while the injected store only // writes EF, so push the new state into the cache store too — otherwise flips lag until cache expiry. diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs index e21fc9e4fe..16f94ea406 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs @@ -10,6 +10,7 @@ using FSH.Modules.Multitenancy.Contracts.Dtos; using FSH.Modules.Multitenancy.Data; using FSH.Modules.Multitenancy.Domain; +using FSH.Modules.Multitenancy.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; @@ -220,7 +221,11 @@ public async Task SetAsDefaultThemeAsync(string tenantId, CancellationToken ct = var currentTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id; if (currentTenantId != MultitenancyConstants.Root.Id) { - throw new ForbiddenException("Only the root tenant can set the default theme"); + throw new ForbiddenException("Only the root tenant can set the default theme") + { + MessageKey = "Multitenancy.OnlyRootCanSetDefaultTheme", + ResourceSource = typeof(MultitenancyResources), + }; } // Clear existing default @@ -240,7 +245,12 @@ public async Task SetAsDefaultThemeAsync(string tenantId, CancellationToken ct = if (entity is null) { - throw new NotFoundException($"Theme for tenant {tenantId} not found"); + throw new NotFoundException($"Theme for tenant {tenantId} not found") + { + MessageKey = "Multitenancy.TenantThemeNotFound", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; } entity.IsDefault = true; diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs index 4f46f535b0..8d7676c23a 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs @@ -16,7 +16,13 @@ public async ValueTask Handle(GetUnreadCountQuery query, CancellationToken { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); return await db.Notifications.AsNoTracking() diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs index cf3f1968e3..d5458aac42 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs @@ -19,7 +19,13 @@ public async ValueTask> Handle(ListNotificat { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); int page = Math.Max(1, q.Page); diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs index d565f1e73f..2d8f3d83ab 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs @@ -16,7 +16,13 @@ public async ValueTask Handle(MarkAllNotificationsReadCommand cmd, Cancella { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); var now = DateTime.UtcNow; diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs index 0fb8ead556..ea73ece82a 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Notifications.Contracts.v1.Commands; using FSH.Modules.Notifications.Data; +using FSH.Modules.Notifications.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,13 @@ public async ValueTask Handle(MarkNotificationReadCommand cmd, Cancellatio { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); // Caller-scoped: filter by (Id, UserId) so users can only mutate their own rows. Returns @@ -24,7 +31,11 @@ public async ValueTask Handle(MarkNotificationReadCommand cmd, Cancellatio var notification = await db.Notifications .FirstOrDefaultAsync(n => n.Id == cmd.NotificationId && n.UserId == currentUserId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Notification not found."); + ?? throw new NotFoundException("Notification not found.") + { + MessageKey = "Notifications.NotificationNotFound", + ResourceSource = typeof(NotificationsResources), + }; notification.MarkRead(); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs b/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs index c2ff2d7d3c..d53586b4cf 100644 --- a/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs +++ b/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs @@ -1,3 +1,4 @@ +// TODO(i18n): email bodies are localized in a follow-up PR — recipient locale must be propagated (no HTTP request culture in background handlers). using System.Globalization; namespace FSH.Modules.Notifications.IntegrationEventHandlers; diff --git a/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.cs b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.cs new file mode 100644 index 0000000000..e67084865a --- /dev/null +++ b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Notifications.Localization; + +/// Marker type binding IStringLocalizer<NotificationsResources> to the Notifications resx catalog. +public sealed class NotificationsResources; diff --git a/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.pt-BR.resx b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.pt-BR.resx new file mode 100644 index 0000000000..4d02b7864a --- /dev/null +++ b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.pt-BR.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Notificação não encontrada. + + diff --git a/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.resx b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.resx new file mode 100644 index 0000000000..6db9a0e6e8 --- /dev/null +++ b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Notification not found. + + diff --git a/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj b/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj index e97c3074bb..77ee76efc8 100644 --- a/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj +++ b/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj @@ -3,7 +3,9 @@ FSH.Modules.Notifications FSH.Modules.Notifications - $(NoWarn);CA1031;CA1711;CA1812;CA1859;CA1002;CA2227;S3267 + + + $(NoWarn);CA1031;CA1711;CA1812;CA1859;CA1002;CA2227;S3267;S2094;S1135 diff --git a/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs b/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs index 939d27256e..b932b9dedf 100644 --- a/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs +++ b/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs @@ -3,6 +3,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.Dtos; using FSH.Modules.Tickets.Domain.Events; +using FSH.Modules.Tickets.Localization; namespace FSH.Modules.Tickets.Domain; @@ -120,7 +121,11 @@ public void Resolve(string? resolutionNote) throw new CustomException( "A closed ticket cannot be resolved — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.ClosedCannotResolve", + ResourceSource = typeof(TicketsResources), + }; } if (Status == TicketStatus.Resolved) { @@ -148,7 +153,12 @@ public void Close() throw new CustomException( $"Only a resolved ticket can be closed — current status is {Status}. Resolve it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.OnlyResolvedCanClose", + MessageArgs = [Status], + ResourceSource = typeof(TicketsResources), + }; } ClosedAtUtc = DateTime.UtcNow; @@ -168,7 +178,11 @@ public void UpdateDetails(string title, string? description, TicketPriority prio throw new CustomException( "A closed ticket cannot be edited — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.ClosedCannotEdit", + ResourceSource = typeof(TicketsResources), + }; } Title = title.Trim(); @@ -201,7 +215,11 @@ public Guid AddComment(Guid authorUserId, string body) throw new CustomException( "A closed ticket cannot accept new comments — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.ClosedCannotComment", + ResourceSource = typeof(TicketsResources), + }; } var comment = TicketComment.Create(Id, authorUserId, body); @@ -234,7 +252,12 @@ private void ThrowIfClosedOrResolved(string action) throw new CustomException( $"Cannot {action} a ticket in status {Status} — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.CannotActionInStatus", + MessageArgs = [action, Status], + ResourceSource = typeof(TicketsResources), + }; } } } diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs index 45189fae0a..bc206ff847 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; +using FSH.Modules.Tickets.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -23,7 +24,11 @@ public async ValueTask Handle(AddTicketCommentCommand command, Cancellatio throw new CustomException( "Cannot post a comment without an authenticated author.", (IEnumerable?)null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + { + MessageKey = "Tickets.CommentAuthorRequired", + ResourceSource = typeof(TicketsResources), + }; } // Load the Comments collection up front so EF's change tracker detects the new TicketComment @@ -32,7 +37,12 @@ public async ValueTask Handle(AddTicketCommentCommand command, Cancellatio .Include(t => t.Comments) .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; var commentId = ticket.AddComment(authorId, command.Body); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs index 6c106b4126..b10b921d28 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(AssignTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Assign(command.AssigneeUserId); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs index eb58248a2f..df5ed4b12b 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(CloseTicketCommand command, CancellationToke var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Close(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs index 41dda7cd16..735027b7a6 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; +using FSH.Modules.Tickets.Localization; using Mediator; using FSH.Framework.Persistence; using Microsoft.EntityFrameworkCore; @@ -26,7 +27,11 @@ public async ValueTask Handle(CreateTicketCommand command, CancellationTok throw new CustomException( "Cannot create a ticket without an authenticated reporter.", (IEnumerable?)null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + { + MessageKey = "Tickets.ReporterRequired", + ResourceSource = typeof(TicketsResources), + }; } // Sequential, tenant-scoped ticket numbers (TK-1, …). Count ALL rows incl. soft-deleted so a diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs index ff513feb02..32bc9a1013 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(DeleteTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; // Soft delete: the audit interceptor converts the EF Delete into an IsDeleted flip. // Comments are not auto-included, so they are left untouched and survive a Restore. diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs index fd5e439bae..06dc641b70 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.Dtos; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; @@ -22,7 +23,12 @@ public async ValueTask Handle(GetTicketByIdQuery query, CancellationT if (ticket is null) { - throw new NotFoundException($"Ticket {query.TicketId} not found."); + throw new NotFoundException($"Ticket {query.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [query.TicketId], + ResourceSource = typeof(TicketsResources), + }; } int commentCount = await dbContext.TicketComments diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs index cc3effd3c4..9a865168e6 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.Dtos; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; @@ -25,7 +26,12 @@ public async ValueTask> Handle( .ConfigureAwait(false); if (!ticketExists) { - throw new NotFoundException($"Ticket {query.TicketId} not found."); + throw new NotFoundException($"Ticket {query.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [query.TicketId], + ResourceSource = typeof(TicketsResources), + }; } var comments = await dbContext.TicketComments diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs index c327bdb0a2..7c17450ed9 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(ReopenTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Reopen(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs index 136f15734e..ea57e17ff6 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(ResolveTicketCommand command, CancellationTo var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Resolve(command.ResolutionNote); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs index e6faf03b04..385312559a 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Persistence; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using Mediator; @@ -18,7 +19,12 @@ public async ValueTask Handle(RestoreTicketCommand command, CancellationTo .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs index 72812e96df..b6378e2337 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(UpdateTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.UpdateDetails(command.Title, command.Description, command.Priority); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.cs b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.cs new file mode 100644 index 0000000000..472be0e99e --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Tickets.Localization; + +/// Marker type binding IStringLocalizer<TicketsResources> to the Tickets resx catalog. +public sealed class TicketsResources; diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx new file mode 100644 index 0000000000..aecc4dcec4 --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Chamado {0} não encontrado. + + + Um chamado fechado não pode ser resolvido. Reabra-o primeiro. + + + Somente um chamado resolvido pode ser fechado. O status atual é {0}. Resolva-o primeiro. + + + Um chamado fechado não pode ser editado. Reabra-o primeiro. + + + Um chamado fechado não aceita novos comentários. Reabra-o primeiro. + + + Não é possível {0} um chamado no status {1}. Reabra-o primeiro. + + + Não é possível publicar um comentário sem um autor autenticado. + + + Não é possível criar um chamado sem um solicitante autenticado. + + diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx new file mode 100644 index 0000000000..6c74050874 --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ticket {0} not found. + + + A closed ticket cannot be resolved — reopen it first. + + + Only a resolved ticket can be closed — current status is {0}. Resolve it first. + + + A closed ticket cannot be edited — reopen it first. + + + A closed ticket cannot accept new comments — reopen it first. + + + Cannot {0} a ticket in status {1} — reopen it first. + + + Cannot post a comment without an authenticated author. + + + Cannot create a ticket without an authenticated reporter. + + diff --git a/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj b/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj index f0058bb022..5324c71542 100644 --- a/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj +++ b/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj @@ -3,7 +3,8 @@ FSH.Modules.Tickets FSH.Modules.Tickets - $(NoWarn);CA1031;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs index 651f842910..638049555a 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs @@ -1,19 +1,21 @@ using FluentValidation; using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription; +using FSH.Modules.Webhooks.Localization; using FSH.Modules.Webhooks.Services; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription; public sealed class CreateWebhookSubscriptionCommandValidator : AbstractValidator { - public CreateWebhookSubscriptionCommandValidator() + public CreateWebhookSubscriptionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Url).NotEmpty() .Must(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) - .WithMessage("A valid absolute URL is required.") + .WithMessage(_ => localizer["Validation.WebhookUrlInvalid"]) .Must(url => !Uri.TryCreate(url, UriKind.Absolute, out var uri) || !WebhookUrlGuard.IsBlockedHost(uri.Host)) - .WithMessage("The URL must not target a private, loopback, link-local, or metadata address."); - RuleFor(x => x.Events).NotEmpty().WithMessage("At least one event type is required."); + .WithMessage(_ => localizer["Validation.WebhookUrlBlockedTarget"]); + RuleFor(x => x.Events).NotEmpty().WithMessage(_ => localizer["Validation.WebhookEventsRequired"]); } } diff --git a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs index 2b550396df..d879925855 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Webhooks.Contracts.v1.DeleteWebhookSubscription; using FSH.Modules.Webhooks.Data; +using FSH.Modules.Webhooks.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(DeleteWebhookSubscriptionCommand command, Ca var subscription = await dbContext.Subscriptions .FirstOrDefaultAsync(s => s.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Webhook subscription {command.Id} not found."); + ?? throw new NotFoundException($"Webhook subscription {command.Id} not found.") + { + MessageKey = "Webhooks.SubscriptionNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(WebhooksResources), + }; dbContext.Subscriptions.Remove(subscription); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs index 92b97bc227..69918661b4 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Webhooks.Contracts.v1.TestWebhookSubscription; using FSH.Modules.Webhooks.Data; +using FSH.Modules.Webhooks.Localization; using FSH.Modules.Webhooks.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,12 @@ public async ValueTask Handle(TestWebhookSubscriptionCommand command, Canc .AsNoTracking() .FirstOrDefaultAsync(s => s.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Webhook subscription {command.Id} not found."); + ?? throw new NotFoundException($"Webhook subscription {command.Id} not found.") + { + MessageKey = "Webhooks.SubscriptionNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(WebhooksResources), + }; var testPayload = JsonSerializer.Serialize(new { diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.cs b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.cs new file mode 100644 index 0000000000..dbcd1cec87 --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Webhooks.Localization; + +/// Marker type binding IStringLocalizer<WebhooksResources> to the Webhooks resx catalog. +public sealed class WebhooksResources; diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.pt-BR.resx b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.pt-BR.resx new file mode 100644 index 0000000000..a22a34ec78 --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.pt-BR.resx @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Inscrição de webhook {0} não encontrada. + + + Uma URL absoluta válida é obrigatória. + + + A URL não pode apontar para um endereço privado, de loopback, link-local ou de metadados. + + + É obrigatório pelo menos um tipo de evento. + + diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.resx b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.resx new file mode 100644 index 0000000000..c86f71f6fa --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.resx @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Webhook subscription {0} not found. + + + A valid absolute URL is required. + + + The URL must not target a private, loopback, link-local, or metadata address. + + + At least one event type is required. + + diff --git a/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj b/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj index facaf13e82..6402b01143 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj +++ b/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj @@ -2,7 +2,8 @@ FSH.Modules.Webhooks FSH.Modules.Webhooks - $(NoWarn);CA1031;CA1054;CA1056;CA1308;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1054;CA1056;CA1308;CA1812;CA1859;S3267;S2094 diff --git a/src/Tests/Architecture.Tests/CatalogParityTests.cs b/src/Tests/Architecture.Tests/CatalogParityTests.cs new file mode 100644 index 0000000000..a8a37b5d2e --- /dev/null +++ b/src/Tests/Architecture.Tests/CatalogParityTests.cs @@ -0,0 +1,183 @@ +using FSH.Framework.Core.Localization; +using Shouldly; +using System.Collections; +using System.Globalization; +using System.Reflection; +using System.Resources; +using System.Text.RegularExpressions; +using Xunit; + +namespace Architecture.Tests; + +/// +/// Generic key-parity guard across EVERY resx catalog, discovered by reflection. +/// +/// Each module already has a hand-written parity test, but those only cover the modules +/// someone remembered to write one for — Notifications shipped a catalog with no parity +/// test at all, and has no test project to put one in. This closes that class of gap: a +/// new module catalog is covered the moment its assembly lands in the output, with no new +/// test and no new test project. +/// +/// Parity matters because a key missing from a translated catalog does not fail — resource +/// fallback quietly serves the neutral (English) string, so an untranslated message ships +/// looking translated. +/// +public sealed class CatalogParityTests +{ + /// + /// A catalog marker is a type with an embedded `.resources` manifest matching its own + /// full name — which is exactly the co-located `ResourcesPath = ""` convention the + /// framework relies on. Anything else named `*Resources` is skipped. + /// + private static List DiscoverCatalogMarkers() + { + var assemblies = ModuleAssemblyDiscovery.GetModuleAssemblies() + .Append(typeof(SharedResources).Assembly) + .Distinct() + .ToList(); + + var markers = new List(); + foreach (var assembly in assemblies) + { + var manifests = assembly.GetManifestResourceNames(); + foreach (var type in SafeGetTypes(assembly)) + { + if (!type.IsClass || type.FullName is null) continue; + if (!type.Name.EndsWith("Resources", StringComparison.Ordinal)) continue; + if (manifests.Contains($"{type.FullName}.resources", StringComparer.Ordinal)) + { + markers.Add(type); + } + } + } + + return markers.OrderBy(t => t.FullName, StringComparer.Ordinal).ToList(); + } + + private static IEnumerable SafeGetTypes(Assembly assembly) + { + try { return assembly.GetTypes(); } + catch (ReflectionTypeLoadException ex) { return ex.Types.Where(t => t is not null)!; } + } + + /// + /// Keys declared by this culture's OWN catalog. `tryParents: false` is the whole point: + /// with parent fallback on, a missing pt-BR key would be answered by the neutral catalog + /// and parity would look perfect while half the strings were English. + /// + private static Dictionary? OwnEntries(ResourceManager manager, CultureInfo culture) + { + var set = manager.GetResourceSet(culture, createIfNotExists: true, tryParents: false); + if (set is null) return null; + + var entries = new Dictionary(StringComparer.Ordinal); + foreach (DictionaryEntry entry in set) + { + if (entry.Key is string key) + { + entries[key] = entry.Value as string ?? string.Empty; + } + } + + return entries; + } + + /// + /// The `{0}`-style argument indexes a message consumes. Escaped braces (`{{`, `}}`) are + /// stripped first so a literal brace is not mistaken for a placeholder. + /// + private static SortedSet PlaceholderIndexes(string value) + { + var unescaped = value.Replace("{{", string.Empty, StringComparison.Ordinal) + .Replace("}}", string.Empty, StringComparison.Ordinal); + + var indexes = new SortedSet(); + foreach (var match in PlaceholderPattern.Matches(unescaped).Cast()) + { + indexes.Add(int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture)); + } + + return indexes; + } + + private static readonly Regex PlaceholderPattern = + new(@"\{(\d+)(?::[^}]*)?\}", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + [Fact] + public void Every_Catalog_Has_Matching_Keys_In_Every_Supported_Culture() + { + var markers = DiscoverCatalogMarkers(); + + // Never let a discovery regression read as a pass: if the reflection stops finding + // catalogs, this test would otherwise assert nothing and go green. + markers.Count.ShouldBeGreaterThanOrEqualTo( + 11, + "expected the Core catalog plus one per module; discovery found fewer, so this " + + "test would silently stop guarding the ones it lost"); + + var violations = new List(); + + foreach (var marker in markers) + { + var manager = new ResourceManager(marker); + + var neutral = OwnEntries(manager, CultureInfo.InvariantCulture); + if (neutral is null || neutral.Count == 0) + { + violations.Add($"{marker.FullName}: neutral catalog is missing or empty"); + continue; + } + + foreach (var tag in SupportedCultures.Tags) + { + // The neutral catalog IS the default culture's catalog; there is no + // `*.en-US.resx` and there should not be one. + if (tag == SupportedCultures.Default) continue; + + var translated = OwnEntries(manager, new CultureInfo(tag)); + if (translated is null) + { + violations.Add($"{marker.FullName}: no `.{tag}.resx` catalog at all"); + continue; + } + + var missing = neutral.Keys.Except(translated.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal).ToList(); + var extra = translated.Keys.Except(neutral.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal).ToList(); + + if (missing.Count > 0) + { + violations.Add($"{marker.FullName} [{tag}]: missing {missing.Count} key(s) — {string.Join(", ", missing)}"); + } + + if (extra.Count > 0) + { + violations.Add($"{marker.FullName} [{tag}]: {extra.Count} key(s) not in the neutral catalog — {string.Join(", ", extra)}"); + } + + // Matching keys are not enough. The caller passes ONE argument list for every + // culture, so a translation consuming a different set of `{n}` placeholders than + // the neutral string either drops data silently or throws FormatException at + // render time — in the translated culture only, i.e. never on the reviewer's + // machine. `{1}` present in Portuguese but not English is the dangerous + // direction: string.Format throws when the index is out of range. + foreach (var key in neutral.Keys.Intersect(translated.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + var neutralArgs = PlaceholderIndexes(neutral[key]); + var translatedArgs = PlaceholderIndexes(translated[key]); + + if (!neutralArgs.SetEquals(translatedArgs)) + { + violations.Add( + $"{marker.FullName} [{tag}] key '{key}': placeholder mismatch — neutral uses " + + $"{{{string.Join(",", neutralArgs)}}} but {tag} uses {{{string.Join(",", translatedArgs)}}}"); + } + } + } + } + + violations.ShouldBeEmpty( + "Every resx catalog must declare the same keys in every supported culture. A key " + + "present only in the neutral catalog falls back to English and ships as if it were " + + "translated. Violations:\n " + string.Join("\n ", violations)); + } +} diff --git a/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs b/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs new file mode 100644 index 0000000000..3ee4bfbd3b --- /dev/null +++ b/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Auditing.Tests.Localization; + +// Proves the AuditingResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class AuditingResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(AuditingResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // AuditingResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // AuditingResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Validation.DateRangeOrder"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Validation.DateRangeOrder' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("FromUtc must be less than or equal to ToUtc."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Validation.DateRangeOrder"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Validation.DateRangeOrder' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("FromUtc deve ser menor ou igual a ToUtc."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs b/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs new file mode 100644 index 0000000000..6387ecfd6c --- /dev/null +++ b/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Billing.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Billing.Tests.Localization; + +// Proves the BillingResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class BillingResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(BillingResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // BillingResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // BillingResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Billing.OnlyRootOperatorMayGenerateInvoices"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Billing.OnlyRootOperatorMayGenerateInvoices' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("Only the root operator may generate invoices across tenants."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Billing.OnlyRootOperatorMayGenerateInvoices"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Billing.OnlyRootOperatorMayGenerateInvoices' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Apenas o operador raiz pode gerar faturas entre tenants."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs b/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs new file mode 100644 index 0000000000..c77c031bf9 --- /dev/null +++ b/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs @@ -0,0 +1,104 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Catalog.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Catalog.Tests.Localization; + +// Proves the CatalogResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class CatalogResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(CatalogResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // CatalogResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // CatalogResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Catalog.CategoryCannotBeOwnParent"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Catalog.CategoryCannotBeOwnParent' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("A category cannot be its own parent."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Catalog.CategoryCannotBeOwnParent"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Catalog.CategoryCannotBeOwnParent' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Uma categoria não pode ser pai de si mesma."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // The AdjustStock domain overflow message is localized with two positional args ({0}=delta, {1}=current stock). + [Fact] + public void StockAdjustmentNegative_formats_args_in_both_cultures() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Catalog.StockAdjustmentNegative", -4, 3]; + en.ResourceNotFound.ShouldBeFalse(); + en.Value.ShouldBe("Stock adjustment of -4 would result in negative stock (current: 3)."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Catalog.StockAdjustmentNegative", -4, 3]; + pt.ResourceNotFound.ShouldBeFalse(); + pt.Value.ShouldBe("O ajuste de estoque de -4 resultaria em estoque negativo (atual: 3)."); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Catalog.Tests/Support/CatalogResourcesLocalizerFactory.cs b/src/Tests/Catalog.Tests/Support/CatalogResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..cc6beedd0a --- /dev/null +++ b/src/Tests/Catalog.Tests/Support/CatalogResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Catalog.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Catalog.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class CatalogResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Catalog.Tests/Validators/AdjustProductStockCommandValidatorTests.cs b/src/Tests/Catalog.Tests/Validators/AdjustProductStockCommandValidatorTests.cs new file mode 100644 index 0000000000..b86c1ec57a --- /dev/null +++ b/src/Tests/Catalog.Tests/Validators/AdjustProductStockCommandValidatorTests.cs @@ -0,0 +1,35 @@ +using Catalog.Tests.Support; +using FSH.Modules.Catalog.Contracts.v1.Products; +using FSH.Modules.Catalog.Features.v1.Products.AdjustProductStock; + +namespace Catalog.Tests.Validators; + +// Exercises the validator with a REAL IStringLocalizer so the localized +// message key ("Validation.DeltaNonZero") is proven to resolve against the embedded catalog. +public sealed class AdjustProductStockCommandValidatorTests +{ + private readonly AdjustProductStockCommandValidator _sut = new(CatalogResourcesLocalizerFactory.Create()); + + [Fact] + public void Delta_Should_Pass_When_NonZero() + { + var command = new AdjustProductStockCommand(Guid.NewGuid(), 5); + + var result = _sut.Validate(command); + + result.Errors.ShouldNotContain(e => e.PropertyName == nameof(AdjustProductStockCommand.Delta)); + } + + [Fact] + public void Delta_Should_Fail_With_LocalizedMessage_When_Zero() + { + var command = new AdjustProductStockCommand(Guid.NewGuid(), 0); + + var result = _sut.Validate(command); + + result.IsValid.ShouldBeFalse(); + result.Errors + .Where(e => e.PropertyName == nameof(AdjustProductStockCommand.Delta)) + .ShouldContain(e => e.ErrorMessage == "Delta must be non-zero."); + } +} diff --git a/src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs b/src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs new file mode 100644 index 0000000000..943d0f9c75 --- /dev/null +++ b/src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Chat.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Chat.Tests.Localization; + +// Proves the ChatResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class ChatResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(ChatResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // ChatResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // ChatResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Chat.ChannelNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Chat.ChannelNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("Channel not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Chat.ChannelNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Chat.ChannelNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Canal não encontrado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Chat.Tests/Support/ChatResourcesLocalizerFactory.cs b/src/Tests/Chat.Tests/Support/ChatResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..5f02505aa0 --- /dev/null +++ b/src/Tests/Chat.Tests/Support/ChatResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Chat.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Chat.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class ChatResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs b/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs new file mode 100644 index 0000000000..f5dfbde6e1 --- /dev/null +++ b/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Files.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Files.Tests.Localization; + +// Proves the FilesResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class FilesResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(FilesResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // FilesResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // FilesResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Files.FileNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Files.FileNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("File not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Files.FileNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Files.FileNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Arquivo não encontrado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Generic.Tests/Support/AuditingResourcesLocalizerFactory.cs b/src/Tests/Generic.Tests/Support/AuditingResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..43d0e9fd0f --- /dev/null +++ b/src/Tests/Generic.Tests/Support/AuditingResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Generic.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so the Auditing validators that require the module +// localizer can be instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class AuditingResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Generic.Tests/Support/SharedResourcesLocalizerFactory.cs b/src/Tests/Generic.Tests/Support/SharedResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..2580fb2deb --- /dev/null +++ b/src/Tests/Generic.Tests/Support/SharedResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Framework.Core.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Generic.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class SharedResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs b/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs index 2fd0253ac6..52215fb250 100644 --- a/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs +++ b/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Localization; using FSH.Modules.Auditing.Contracts.v1.GetAudits; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; @@ -10,6 +11,9 @@ using FSH.Modules.Auditing.Features.v1.GetAuditSummary; using FSH.Modules.Auditing.Features.v1.GetExceptionAudits; using FSH.Modules.Auditing.Features.v1.GetSecurityAudits; +using FSH.Modules.Auditing.Localization; +using Generic.Tests.Support; +using Microsoft.Extensions.Localization; namespace Generic.Tests.Validators; @@ -20,12 +24,14 @@ namespace Generic.Tests.Validators; public sealed class DateRangeValidatorTests { private static readonly DateTime BaseDate = new(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc); + private static readonly IStringLocalizer Localizer = SharedResourcesLocalizerFactory.Create(); + private static readonly IStringLocalizer AuditingLocalizer = AuditingResourcesLocalizerFactory.Create(); [Fact] public void DateRange_Should_Pass_When_BothNull_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = null, ToUtc = null }; // Act @@ -39,7 +45,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAudits() public void DateRange_Should_Pass_When_BothNull_GetAuditsByCorrelation() { // Arrange - var validator = new GetAuditsByCorrelationQueryValidator(); + var validator = new GetAuditsByCorrelationQueryValidator(AuditingLocalizer); var query = new GetAuditsByCorrelationQuery { CorrelationId = "test-id", FromUtc = null, ToUtc = null }; // Act @@ -53,7 +59,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAuditsByCorrelation() public void DateRange_Should_Pass_When_BothNull_GetAuditsByTrace() { // Arrange - var validator = new GetAuditsByTraceQueryValidator(); + var validator = new GetAuditsByTraceQueryValidator(AuditingLocalizer); var query = new GetAuditsByTraceQuery { TraceId = "test-trace", FromUtc = null, ToUtc = null }; // Act @@ -67,7 +73,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAuditsByTrace() public void DateRange_Should_Pass_When_BothNull_GetAuditSummary() { // Arrange - var validator = new GetAuditSummaryQueryValidator(); + var validator = new GetAuditSummaryQueryValidator(AuditingLocalizer); var query = new GetAuditSummaryQuery { FromUtc = null, ToUtc = null }; // Act @@ -81,7 +87,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAuditSummary() public void DateRange_Should_Pass_When_OnlyFromUtcSet_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, ToUtc = null }; // Act @@ -95,7 +101,7 @@ public void DateRange_Should_Pass_When_OnlyFromUtcSet_GetAudits() public void DateRange_Should_Pass_When_OnlyToUtcSet_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = null, ToUtc = BaseDate }; // Act @@ -109,7 +115,7 @@ public void DateRange_Should_Pass_When_OnlyToUtcSet_GetAudits() public void DateRange_Should_Pass_When_FromUtcEqualsToUtc_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, ToUtc = BaseDate }; // Act @@ -123,7 +129,7 @@ public void DateRange_Should_Pass_When_FromUtcEqualsToUtc_GetAudits() public void DateRange_Should_Pass_When_FromUtcBeforeToUtc_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, @@ -141,7 +147,7 @@ public void DateRange_Should_Pass_When_FromUtcBeforeToUtc_GetAudits() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate.AddDays(7), @@ -160,7 +166,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAudits() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByCorrelation() { // Arrange - var validator = new GetAuditsByCorrelationQueryValidator(); + var validator = new GetAuditsByCorrelationQueryValidator(AuditingLocalizer); var query = new GetAuditsByCorrelationQuery { CorrelationId = "test-id", @@ -180,7 +186,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByCorrelation( public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByTrace() { // Arrange - var validator = new GetAuditsByTraceQueryValidator(); + var validator = new GetAuditsByTraceQueryValidator(AuditingLocalizer); var query = new GetAuditsByTraceQuery { TraceId = "test-trace", @@ -200,7 +206,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByTrace() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditSummary() { // Arrange - var validator = new GetAuditSummaryQueryValidator(); + var validator = new GetAuditSummaryQueryValidator(AuditingLocalizer); var query = new GetAuditSummaryQuery { FromUtc = BaseDate.AddDays(7), @@ -219,7 +225,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditSummary() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetExceptionAudits() { // Arrange - var validator = new GetExceptionAuditsQueryValidator(); + var validator = new GetExceptionAuditsQueryValidator(AuditingLocalizer); var query = new GetExceptionAuditsQuery { FromUtc = BaseDate.AddDays(7), @@ -238,7 +244,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetExceptionAudits() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetSecurityAudits() { // Arrange - var validator = new GetSecurityAuditsQueryValidator(); + var validator = new GetSecurityAuditsQueryValidator(AuditingLocalizer); var query = new GetSecurityAuditsQuery { FromUtc = BaseDate.AddDays(7), @@ -260,7 +266,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetSecurityAudits() public void DateRange_Should_Pass_When_FromUtcSlightlyBeforeToUtc(int secondsDiff) { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, diff --git a/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs b/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs index 48ed96d00b..843ee8d841 100644 --- a/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs +++ b/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs @@ -1,7 +1,11 @@ +using FSH.Framework.Core.Localization; using FSH.Modules.Auditing.Contracts.v1.GetAudits; using FSH.Modules.Auditing.Features.v1.GetAudits; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; using FSH.Modules.Identity.Features.v1.Users.SearchUsers; +using Generic.Tests.Support; +using Microsoft.Extensions.Localization; namespace Generic.Tests.Validators; @@ -11,10 +15,13 @@ namespace Generic.Tests.Validators; /// public sealed class PagedQueryValidatorTests { + private static readonly IStringLocalizer Localizer = SharedResourcesLocalizerFactory.Create(); + private static readonly IStringLocalizer AuditingLocalizer = AuditingResourcesLocalizerFactory.Create(); + public static TheoryData PagedQueryValidators => new() { - { new GetAuditsQueryValidator(), new GetAuditsQuery() }, - { new SearchUsersQueryValidator(), new SearchUsersQuery() } + { new GetAuditsQueryValidator(Localizer, AuditingLocalizer), new GetAuditsQuery() }, + { new SearchUsersQueryValidator(Localizer), new SearchUsersQuery() } }; [Theory] @@ -35,7 +42,7 @@ public void PageNumber_Should_Pass_When_Null(IValidator validator, object query) public void PageNumber_Should_Pass_When_GreaterThanZero_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = 1 }; // Act @@ -49,7 +56,7 @@ public void PageNumber_Should_Pass_When_GreaterThanZero_Auditing() public void PageNumber_Should_Pass_When_GreaterThanZero_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageNumber = 5 }; // Act @@ -63,7 +70,7 @@ public void PageNumber_Should_Pass_When_GreaterThanZero_Identity() public void PageNumber_Should_Fail_When_Zero_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = 0 }; // Act @@ -77,7 +84,7 @@ public void PageNumber_Should_Fail_When_Zero_Auditing() public void PageNumber_Should_Fail_When_Zero_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageNumber = 0 }; // Act @@ -91,7 +98,7 @@ public void PageNumber_Should_Fail_When_Zero_Identity() public void PageNumber_Should_Fail_When_Negative_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = -1 }; // Act @@ -105,7 +112,7 @@ public void PageNumber_Should_Fail_When_Negative_Auditing() public void PageNumber_Should_Fail_When_Negative_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageNumber = -5 }; // Act @@ -119,7 +126,7 @@ public void PageNumber_Should_Fail_When_Negative_Identity() public void PageSize_Should_Pass_When_Null_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = null }; // Act @@ -133,7 +140,7 @@ public void PageSize_Should_Pass_When_Null_Auditing() public void PageSize_Should_Pass_When_Null_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = null }; // Act @@ -150,7 +157,7 @@ public void PageSize_Should_Pass_When_Null_Identity() public void PageSize_Should_Pass_When_Between1And100_Auditing(int pageSize) { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = pageSize }; // Act @@ -167,7 +174,7 @@ public void PageSize_Should_Pass_When_Between1And100_Auditing(int pageSize) public void PageSize_Should_Pass_When_Between1And100_Identity(int pageSize) { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = pageSize }; // Act @@ -181,7 +188,7 @@ public void PageSize_Should_Pass_When_Between1And100_Identity(int pageSize) public void PageSize_Should_Fail_When_Zero_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = 0 }; // Act @@ -195,7 +202,7 @@ public void PageSize_Should_Fail_When_Zero_Auditing() public void PageSize_Should_Fail_When_Zero_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = 0 }; // Act @@ -209,7 +216,7 @@ public void PageSize_Should_Fail_When_Zero_Identity() public void PageSize_Should_Fail_When_GreaterThan100_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = 101 }; // Act @@ -223,7 +230,7 @@ public void PageSize_Should_Fail_When_GreaterThan100_Auditing() public void PageSize_Should_Fail_When_GreaterThan100_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = 150 }; // Act diff --git a/src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs b/src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs new file mode 100644 index 0000000000..283ab6886b --- /dev/null +++ b/src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Identity.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Identity.Tests.Localization; + +// Proves the IdentityResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class IdentityResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(IdentityResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // IdentityResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // IdentityResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Identity.UserNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Identity.UserNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("User not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Identity.UserNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Identity.UserNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Usuário não encontrado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs index 72e8a0e36f..de8a53d385 100644 --- a/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; using FSH.Modules.Identity.Features.v1.Groups.CreateGroup; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class CreateGroupCommandValidatorTests { - private readonly CreateGroupCommandValidator _sut = new(); + private readonly CreateGroupCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Name Validation diff --git a/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs index 399d4de894..d85b145aa6 100644 --- a/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; using FSH.Modules.Identity.Features.v1.Users.DeleteUser; +using Identity.Tests.Support; using Shouldly; using Xunit; @@ -7,7 +8,7 @@ namespace Identity.Tests.Validators; public sealed class DeleteUserCommandValidatorTests { - private readonly DeleteUserCommandValidator _sut = new(); + private readonly DeleteUserCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); [Fact] public void Validate_Should_Pass_When_IdIsProvided() diff --git a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs index c0c1cdb2b9..305a1c76d7 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; using FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class UpdateGroupCommandValidatorTests { - private readonly UpdateGroupCommandValidator _sut = new(); + private readonly UpdateGroupCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Id Validation diff --git a/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs index 3fe0559c2c..500b8f7a54 100644 --- a/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; using FSH.Modules.Identity.Features.v1.Roles.UpsertRole; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class UpsertRoleCommandValidatorTests { - private readonly UpsertRoleCommandValidator _sut = new(); + private readonly UpsertRoleCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Name Validation diff --git a/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs b/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs new file mode 100644 index 0000000000..1f360adce3 --- /dev/null +++ b/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Multitenancy.Tests.Localization; + +// Proves the MultitenancyResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class MultitenancyResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(MultitenancyResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // MultitenancyResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // MultitenancyResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Multitenancy.RootTenantCannotBeDeactivated"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Multitenancy.RootTenantCannotBeDeactivated' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("The root tenant cannot be deactivated."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Multitenancy.RootTenantCannotBeDeactivated"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Multitenancy.RootTenantCannotBeDeactivated' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("O tenant raiz não pode ser desativado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Multitenancy.Tests/Support/MultitenancyResourcesLocalizerFactory.cs b/src/Tests/Multitenancy.Tests/Support/MultitenancyResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..5d8c6a6236 --- /dev/null +++ b/src/Tests/Multitenancy.Tests/Support/MultitenancyResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Multitenancy.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class MultitenancyResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Tickets.Tests/GlobalUsings.cs b/src/Tests/Tickets.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..3a6ad15e89 --- /dev/null +++ b/src/Tests/Tickets.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Shouldly; +global using Xunit; diff --git a/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs b/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs new file mode 100644 index 0000000000..193ab6f73a --- /dev/null +++ b/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs @@ -0,0 +1,103 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Tickets.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Tickets.Tests.Localization; + +// Proves the TicketsResources catalog is embedded under the correct manifest name so the module +// resx resolves at runtime. A wrong manifest name would flip ResourceNotFound and leak raw keys or +// English text, and a missing pt-BR entry would ship English as if it were translated. Both are caught +// here. This is the module's only unit test project, added when Tickets exception bodies were localized. +public sealed class TicketsResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(TicketsResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // TicketsResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // TicketsResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Tickets.ClosedCannotResolve"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Tickets.ClosedCannotResolve' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("A closed ticket cannot be resolved — reopen it first."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Tickets.ClosedCannotResolve"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Tickets.ClosedCannotResolve' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Um chamado fechado não pode ser resolvido. Reabra-o primeiro."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // TicketNotFound carries the ticket id ({0}); OnlyResolvedCanClose carries the status ({0}). + [Fact] + public void Parameterized_keys_format_args_in_both_cultures() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + localizer["Tickets.TicketNotFound", "abc"].Value.ShouldBe("Ticket abc not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + localizer["Tickets.TicketNotFound", "abc"].Value.ShouldBe("Chamado abc não encontrado."); + localizer["Tickets.OnlyResolvedCanClose", "Open"].Value + .ShouldBe("Somente um chamado resolvido pode ser fechado. O status atual é Open. Resolva-o primeiro."); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Tickets.Tests/Tickets.Tests.csproj b/src/Tests/Tickets.Tests/Tickets.Tests.csproj new file mode 100644 index 0000000000..0cc930f5df --- /dev/null +++ b/src/Tests/Tickets.Tests/Tickets.Tests.csproj @@ -0,0 +1,25 @@ + + + + Tickets.Tests + Tickets.Tests + false + true + $(NoWarn);CA1515;CA1861;CA1707 + + + + + + + + + + + + + + + + + diff --git a/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs b/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs index e4bef7aa96..b43c0313fa 100644 --- a/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs +++ b/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs @@ -2,6 +2,7 @@ using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Services; +using Webhooks.Tests.Support; namespace Webhooks.Tests; @@ -13,7 +14,7 @@ namespace Webhooks.Tests; /// public sealed class CreateWebhookSubscriptionSsrfValidatorTests { - private readonly CreateWebhookSubscriptionCommandValidator _validator = new(); + private readonly CreateWebhookSubscriptionCommandValidator _validator = new(WebhooksResourcesLocalizerFactory.Create()); [Theory] [InlineData("http://169.254.169.254/latest/meta-data/")] // cloud instance metadata diff --git a/src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs b/src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs new file mode 100644 index 0000000000..b09e8ba919 --- /dev/null +++ b/src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Webhooks.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Webhooks.Tests.Localization; + +// Proves the WebhooksResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class WebhooksResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(WebhooksResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // WebhooksResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // WebhooksResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Webhooks.SubscriptionNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Webhooks.SubscriptionNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("Webhook subscription {0} not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Webhooks.SubscriptionNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Webhooks.SubscriptionNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Inscrição de webhook {0} não encontrada."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Webhooks.Tests/Support/WebhooksResourcesLocalizerFactory.cs b/src/Tests/Webhooks.Tests/Support/WebhooksResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..d14626c129 --- /dev/null +++ b/src/Tests/Webhooks.Tests/Support/WebhooksResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Webhooks.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Webhooks.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class WebhooksResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs b/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs index c7794b29a4..54a8953374 100644 --- a/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs +++ b/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs @@ -1,20 +1,25 @@ using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Contracts.v1.DeleteWebhookSubscription; using FSH.Modules.Webhooks.Contracts.v1.TestWebhookSubscription; +using FSH.Modules.Webhooks.Localization; +using Microsoft.Extensions.Localization; using FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Features.v1.DeleteWebhookSubscription; using FSH.Modules.Webhooks.Features.v1.TestWebhookSubscription; +using Webhooks.Tests.Support; namespace Webhooks.Tests.Validators; public sealed class WebhookValidatorTests { + private static readonly IStringLocalizer Localizer = WebhooksResourcesLocalizerFactory.Create(); + #region CreateWebhookSubscription [Fact] public void Create_Should_Pass_When_Url_Absolute_And_Events_Present() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("https://example.com/hook", ["user.created"], "secret"); var result = validator.Validate(command); @@ -25,7 +30,7 @@ public void Create_Should_Pass_When_Url_Absolute_And_Events_Present() [Fact] public void Create_Should_Fail_When_Url_Empty() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand(string.Empty, ["user.created"], null); var result = validator.Validate(command); @@ -37,7 +42,7 @@ public void Create_Should_Fail_When_Url_Empty() [Fact] public void Create_Should_Fail_When_Url_Not_Absolute() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("not-a-url", ["user.created"], null); var result = validator.Validate(command); @@ -49,7 +54,7 @@ public void Create_Should_Fail_When_Url_Not_Absolute() [Fact] public void Create_Should_Fail_When_Url_Relative() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("/relative/path", ["user.created"], null); var result = validator.Validate(command); @@ -60,7 +65,7 @@ public void Create_Should_Fail_When_Url_Relative() [Fact] public void Create_Should_Fail_When_Events_Empty() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("https://example.com", [], null); var result = validator.Validate(command);