diff --git a/.agents/rules/api-conventions.md b/.agents/rules/api-conventions.md index 0c9550b14b..eab169f9d0 100644 --- a/.agents/rules/api-conventions.md +++ b/.agents/rules/api-conventions.md @@ -47,6 +47,9 @@ Throw framework exception types; the global handler converts to RFC 9457 `Proble | `UnauthorizedException` | 401 | | `CustomException(msg, errors?, HttpStatusCode)` | as specified (default 400) | +Localize every client-visible throw: English text as the message (logs), key attached with +`.Localized<{X}Resources>("{X}.Case", args)` or `.Localized("Error.Case")` for a Core key. See `localization.md`. + Don't catch broadly to swallow. Background loops may `catch (Exception)` to stay alive, but must **log with context** and exclude `OperationCanceledException` (filtered catch or a preceding `catch (OperationCanceledException)`). ## Permissions diff --git a/.agents/rules/localization.md b/.agents/rules/localization.md index 4a884040f2..d1ab7bbadf 100644 --- a/.agents/rules/localization.md +++ b/.agents/rules/localization.md @@ -23,24 +23,20 @@ Key naming: `Error..` for domain messages (`Catalog.ProductNotFoun ## Exceptions — localize at the boundary, log stays English -Throw with the **English message** as `Exception.Message` (used for logs and fallback) plus the resource key metadata. **Never** pre-localize the message at the throw site. +Throw with the **English message** as `Exception.Message` (used for logs and fallback) and attach the resource key with `.Localized(...)` on any `CustomException` subclass. **Never** pre-localize the message at the throw site. ```csharp -// domain message -> module catalog +// domain message -> module catalog (the marker type names the catalog) throw new NotFoundException($"Product {id} not found.") -{ - MessageKey = "Catalog.ProductNotFound", - MessageArgs = [id], - ResourceSource = typeof(CatalogResources), -}; + .Localized("Catalog.ProductNotFound", id); -// cross-cutting message -> Core catalog (ResourceSource omitted = SharedResources) +// cross-cutting message -> Core catalog (no type argument = SharedResources) throw new UnauthorizedException("Tenant context is required.") -{ - MessageKey = "Error.TenantContextRequired", -}; + .Localized("Error.TenantContextRequired"); ``` +`.Localized` returns the same instance, so `throw` keeps the subclass and its status code. Its arguments are `params object[]`: pass a list as one string (`string.Join(", ", ids)`), because a `string[]` argument binds as the whole argument array and only its first item reaches `{0}`; never pass a `null` array. `MessageKey`, `MessageArgs` and `ResourceSource` have private setters: `.Localized` is the only way to set them. The BCL wrappers (`LocalizedKeyNotFoundException`, `LocalizedUnauthorizedAccessException`) do not derive from `CustomException` and keep the `{ MessageKey = …, ResourceSource = … }` initializer. + `GlobalExceptionHandler` resolves `Title` (by status) and `Detail` (via `MessageKey` + `ResourceSource`) under the request culture, and falls back to `Exception.Message` when the key is missing (`ResourceNotFound`) or malformed (`FormatException`). Migration is therefore incremental: an un-migrated `throw new NotFoundException("...")` still renders its English literal. **Do NOT** set `ProblemDetails` from a localized string in logs — the handler logs `Exception.Message` (English) and the type name, never the translated body. @@ -69,7 +65,7 @@ Always the `.WithMessage(_ => localizer["Key"])` lambda (resolution is deferred ## Tests (required with every catalog change) - **Parity** — add every new key to the neutral (English) catalog; the `pt-BR` entry is optional, and a key it lacks falls back to English at runtime, so nobody has to write Portuguese to get a green build. The build fails only on a `pt-BR` key the neutral catalog does not have (an orphan, usually a renamed or deleted key) or on a `pt-BR` value whose `{n}` placeholders differ from the English one. Applies to Core and every `Resources`. Per-catalog tests live in each module's test project; `CatalogParityTests` in `Architecture.Tests` enumerates every module catalog generically, so a **new** module catalog is covered without adding a test. -- **Code → resx guard** — every referenced key (`MessageKey`, `localizer["…"]`) must exist in its catalog, or the build fails. This is what catches a forgotten/typo `ResourceSource` (which would otherwise fall back silently). +- **Code → resx guard** — `LocalizedKeyReferenceTests` (`Architecture.Tests`) scans `src/BuildingBlocks` and `src/Modules` for every literal key passed to `.Localized(…)`, `.Localized(…)`, a wrapper's `MessageKey = "…"` and `localizer["…"]`, and fails when the key is missing from the neutral catalog it targets or when the entry's `{n}` placeholders need more arguments than the call passes. This is what catches a typo or the wrong catalog, which would otherwise fall back to English (or show the raw key) silently. It resolves a `…localizer` / `…Localizer` name through the file's `IStringLocalizer` declaration, so a localizer of unknown type fails too; a localizer field named otherwise is not scanned. Only string-literal keys are checked: a `.Localized(...)` call whose key is a variable fails until the site is listed in `NonLiteralKeySites`, and an indexer with a variable key (`localizer[titleKey]`) is skipped entirely, so keep those lookups few and covered by a test of their own. - Build validators/handlers with a real localizer from the embedded catalog via `SharedResourcesLocalizerFactory.Create()` (test-project `Support/` helper), not a stub. ## Emails / background handlers diff --git a/.agents/skills/add-entity/SKILL.md b/.agents/skills/add-entity/SKILL.md index 0ddd4f0c6a..9b6d5ca36a 100644 --- a/.agents/skills/add-entity/SKILL.md +++ b/.agents/skills/add-entity/SKILL.md @@ -56,6 +56,33 @@ public sealed class {Entity} : AggregateRoot, IHasTenant, IAuditableEntity Notes: setters are `private set`; `TenantId`/audit/soft-delete members are settable by the framework (interceptor + Finbuckle) so they aren't `private set`. Use `Guid.CreateVersion7()`, never `Guid.NewGuid()`. +## Business-rule violations — localized `CustomException` + +`ArgumentException` guards are for programming errors (they surface as a 500). A rule a client can +break (an illegal state transition, a duplicate) throws a `CustomException` with the matching status, +English text as the message (for logs) and the key attached with `.Localized`: + +```csharp +public void Archive() +{ + if (Status == {Entity}Status.Archived) + { + throw new CustomException( + $"{Entity} {Id} is already archived.", + errors: null, + HttpStatusCode.Conflict) + .Localized<{X}Resources>("{X}.{Entity}AlreadyArchived", Id); + } + + Status = {Entity}Status.Archived; +} +``` + +Add the key in English to `src/Modules/{X}/Modules.{X}/Localization/{X}Resources.resx` (`{0}` for each +argument, in order); the pt-BR entry is optional. Enum arguments are translated when the catalog has a +`{EnumType}.{Member}` entry. `LocalizedKeyReferenceTests` fails the build on a missing key. Rules: +`.agents/rules/localization.md`. + ## Domain event — inherit `DomainEvent` (abstract record) ```csharp @@ -121,5 +148,6 @@ dotnet ef migrations add Add{Entity} \ - [ ] `sealed`, `AggregateRoot` (+ `IHasTenant`/`IAuditableEntity`/`ISoftDeletable` as needed), private ctor, static `Create` using `Guid.CreateVersion7()` - [ ] Domain event inherits `DomainEvent`; raised via `DomainEvent.Create` + `AddDomainEvent` +- [ ] Client-breakable rules throw `CustomException(...).Localized<{X}Resources>("…")`; keys added in English to `{X}Resources.resx` - [ ] EF config: no manual soft-delete/tenant filter; `ValueGeneratedNever()` on nav-collection children - [ ] `DbSet` added; build green; migration created with `--context {X}DbContext` diff --git a/.agents/skills/add-feature/SKILL.md b/.agents/skills/add-feature/SKILL.md index cc0953e690..682c851e51 100644 --- a/.agents/skills/add-feature/SKILL.md +++ b/.agents/skills/add-feature/SKILL.md @@ -57,17 +57,41 @@ public sealed class Create{Entity}CommandHandler(CatalogDbContext dbContext) } ``` -Throw `NotFoundException` / `CustomException(msg, errors, HttpStatusCode)` (`FSH.Framework.Core.Exceptions`) — the global handler maps them to ProblemDetails. +Throw `NotFoundException` / `CustomException(msg, errors, HttpStatusCode)` (`FSH.Framework.Core.Exceptions`) — the global handler maps them to ProblemDetails. **Every throw a client can see is localized**: keep the English text as the message (it is what the logs show) and attach the key with `.Localized`: + +```csharp +var entity = await dbContext.{Entities}.FindAsync([command.Id], cancellationToken).ConfigureAwait(false) + ?? throw new NotFoundException($"{Entity} {command.Id} not found.") + .Localized<{X}Resources>("{X}.{Entity}NotFound", command.Id); + +// generic message already in the Core catalog: no type argument +throw new UnauthorizedException("No current user.").Localized("Error.NoCurrentUser"); +``` + +## Step 2b — Add the keys to the module catalog + +Add each new key to `src/Modules/{X}/Modules.{X}/Localization/{X}Resources.resx` in **English**, with `{0}`, `{1}` for the arguments in the order you pass them. The `{X}Resources.pt-BR.resx` entry is optional: a missing translation falls back to English. Reuse a `SharedResources` key (`Error.*`, `Validation.*`) instead of duplicating a generic message. + +```xml + + {Entity} {0} was not found. + +``` + +`LocalizedKeyReferenceTests` fails the build if a key is missing from the catalog you named, or if its placeholders need more arguments than you passed. Rules: `.agents/rules/localization.md`. ## Step 3 — Validator (required; same folder) +Inject the module's localizer and resolve each custom message lazily with `.WithMessage(_ => localizer["…"])`. Built-in FluentValidation messages (`NotEmpty`, `MaximumLength`, …) come translated from FluentValidation's own language pack under the request culture and need no key (the property name inside them stays as declared). + ```csharp public sealed class Create{Entity}CommandValidator : AbstractValidator { - public Create{Entity}CommandValidator() + public Create{Entity}CommandValidator(IStringLocalizer<{X}Resources> localizer) { RuleFor(x => x.Name).NotEmpty().MaximumLength(200); - RuleFor(x => x.PriceCurrency).NotEmpty().Length(3); + RuleFor(x => x.PriceCurrency).NotEmpty().Length(3) + .WithMessage(_ => localizer["Validation.{Entity}CurrencyCode"]); } } ``` @@ -107,7 +131,9 @@ dotnet test src/Tests/{X}.Tests # + add a handler/validator test (see - [ ] Command/Query in the **Contracts** project (`using Mediator;`), DTOs in `Contracts/Dtos/` - [ ] Handler `public sealed`, injects `{X}DbContext` (no repository), `ValueTask` + `.ConfigureAwait(false)` -- [ ] `{Name}Validator` exists +- [ ] Every client-visible throw ends in `.Localized<{X}Resources>("…")` (or `.Localized("…")` for a Core key); English text kept as the message +- [ ] New keys added in English to `{X}Resources.resx` (pt-BR optional); `LocalizedKeyReferenceTests` green +- [ ] `{Name}Validator` exists; custom messages via `IStringLocalizer<{X}Resources>` + `.WithMessage(_ => localizer["…"])` - [ ] Endpoint `internal static …Map{Feature}Endpoint`, `.RequirePermission(...)`, `.WithName/.WithSummary` - [ ] Wired in `{X}Module.MapEndpoints` - [ ] Build 0 warnings; test added diff --git a/.agents/skills/add-full-slice/SKILL.md b/.agents/skills/add-full-slice/SKILL.md index 2ef5ad7e5d..6ad643c21a 100644 --- a/.agents/skills/add-full-slice/SKILL.md +++ b/.agents/skills/add-full-slice/SKILL.md @@ -15,7 +15,8 @@ the order of operations and the **contract** that keeps the two halves in sync. 1. **Backend slice** — `add-feature` (and `add-entity` first if a new entity is needed): - Command/Query + response DTO in `Modules.{X}.Contracts/v1/{Area}/` (+ `Contracts/Dtos/`). - Handler (`public sealed`, injects `{X}DbContext`), Validator, Endpoint (`internal static Map…Endpoint`, `.RequirePermission(...)`). - - Wire in `{X}Module.MapEndpoints`. Build + test backend green. + - Localized errors: every client-visible throw ends in `.Localized<{X}Resources>("…", args)`, validator messages go through `IStringLocalizer<{X}Resources>`, and each new key is added in English to `{X}Resources.resx` (pt-BR optional). The API then answers in the caller's language, so the page shows `problem.detail` as is and never re-translates it. + - Wire in `{X}Module.MapEndpoints`. Build + test backend green (`LocalizedKeyReferenceTests` included). 2. **Lock the contract** — note the final **route path**, HTTP method, request shape, and response DTO field names/casing. The React side must match these exactly. 3. **Frontend page** — `add-react-page` in the chosen app: - API module calls the **same path**; hand-write TS types mirroring the **response DTO** (the API serializes C# records as camelCase JSON — TS fields are camelCase even though admin *query params* are PascalCase). @@ -43,7 +44,7 @@ cd clients/{app} && npm run lint && npm run test:e2e ## Checklist -- [ ] Backend slice complete + green (`add-feature`) +- [ ] Backend slice complete + green (`add-feature`), throws and validator messages localized with keys in `{X}Resources.resx` - [ ] Contract locked: route, request shape, response DTO field names - [ ] Frontend api module path + TS types match the contract (body JSON camelCase) - [ ] Page + route added (`add-react-page`); admin permission mirrored + gated (`add-permission`) diff --git a/.agents/skills/add-module/SKILL.md b/.agents/skills/add-module/SKILL.md index 67bfdc761c..cdbae1968d 100644 --- a/.agents/skills/add-module/SKILL.md +++ b/.agents/skills/add-module/SKILL.md @@ -95,6 +95,36 @@ public sealed class {Name}DbContext : BaseDbContext } ``` +## Step 3b — Localization catalog (module-owned messages) + +Every client-visible message the module throws or validates lives in its own resx catalog, so the +module ships localized from its first feature. Mirror `Modules.Tickets/Localization/`: + +```csharp +// src/Modules/{Name}/Modules.{Name}/Localization/{Name}Resources.cs +namespace FSH.Modules.{Name}.Localization; + +/// Marker type binding IStringLocalizer<{Name}Resources> to the {Name} resx catalog. +public sealed class {Name}Resources; +``` + +- `Localization/{Name}Resources.resx` next to the marker: the neutral (English) catalog. Copy + `TicketsResources.resx` and delete its `` entries. `Localization/{Name}Resources.pt-BR.resx` + is optional; a missing translation falls back to English. +- The embedded resource must be named after the marker's full name + (`FSH.Modules.{Name}.Localization.{Name}Resources`). Because each resx sits next to a `.cs` file of the + same base name, the SDK names it after the class declared in that file + (`EmbeddedResourceUseDependentUponConvention`), so keep the marker's class name equal to its file + name; no `` item is needed. +- Add `S2094` to the runtime csproj's `` (the marker is an empty class on purpose). +- No per-module registration: `AddHeroLocalization` in the host serves every catalog. + +Throws use `.Localized<{Name}Resources>("{Name}.Case", args)` and validators inject +`IStringLocalizer<{Name}Resources>` (see **add-feature**). Once the module is referenced from +`FSH.Starter.Api` (Step 4), `Architecture.Tests` picks the catalog up on its own: `CatalogParityTests` +checks the pt-BR file against the neutral one and `LocalizedKeyReferenceTests` fails on any key the +code uses that the catalog lacks, which also catches a wrong manifest name. Rules: `.agents/rules/localization.md`. + ## Step 4 — Solution + project references ```bash @@ -132,6 +162,7 @@ dotnet test src/FSH.Starter.slnx - [ ] `IModule`: `AddHeroDbContext()`, `PermissionConstants.Register`, version-set group, eventing trio if needed - [ ] `{Name}DbContext : BaseDbContext`, 4-arg ctor, `base.OnModelCreating` last - [ ] `{Name}Permissions` in Contracts/Authorization +- [ ] `Localization/{Name}Resources.cs` marker + `{Name}Resources.resx` (English; pt-BR optional), `S2094` in `` - [ ] Migrations folder + initial migration (`--context {Name}DbContext`) - [ ] **Registered in all four places** (Api + DbMigrator × Mediator + moduleAssemblies) - [ ] Build + Architecture.Tests green diff --git a/.agents/skills/query-patterns/SKILL.md b/.agents/skills/query-patterns/SKILL.md index 60a566e034..8a53bc680f 100644 --- a/.agents/skills/query-patterns/SKILL.md +++ b/.agents/skills/query-patterns/SKILL.md @@ -82,7 +82,8 @@ public sealed class Get{Entity}QueryHandler({X}DbContext dbContext) ArgumentNullException.ThrowIfNull(query); var entity = await dbContext.{Entities}.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == query.Id, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"{Entity} {query.Id} not found"); + ?? throw new NotFoundException($"{Entity} {query.Id} not found") + .Localized<{X}Resources>("{X}.{Entity}NotFound", query.Id); // key in {X}Resources.resx return entity.ToDto(); } } diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 40f81b1f12..b6aa1b0aa0 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -96,7 +96,7 @@ jobs: - name: Run unit tests with coverage run: | - for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Notifications Webhooks; do + for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Notifications Tickets Webhooks; do echo "::group::${proj}.Tests" dotnet test "src/Tests/${proj}.Tests" -c Release --no-build \ --collect:"XPlat Code Coverage" --settings coverage.runsettings \ diff --git a/src/BuildingBlocks/Core/Exceptions/CustomException.cs b/src/BuildingBlocks/Core/Exceptions/CustomException.cs index ddc183c5d7..293a220671 100644 --- a/src/BuildingBlocks/Core/Exceptions/CustomException.cs +++ b/src/BuildingBlocks/Core/Exceptions/CustomException.cs @@ -23,19 +23,59 @@ public class CustomException : Exception, ILocalizableMessage /// Optional resource key resolved against to localize the /// response body under the request culture. When null, the literal /// is used. The message itself always stays the (English) fallback for logs. + /// Set through or . /// - public string? MessageKey { get; init; } + public string? MessageKey { get; private set; } /// /// Format arguments applied to the localized message ({0}, {1}, …). /// - public IReadOnlyList MessageArgs { get; init; } = []; + public IReadOnlyList MessageArgs { get; private set; } = []; /// /// Marker type identifying the resource catalog for . When null, /// the shared (Core) catalog is used; module-specific keys point it at the module's own catalog. /// - public Type? ResourceSource { get; init; } + public Type? ResourceSource { get; private set; } + + /// + /// Localizes the response body from the catalog of (a module's + /// marker, e.g. IdentityResources), keeping the English message as the log fallback: + /// throw new NotFoundException($"User {id} not found.").Localized<IdentityResources>("Identity.UserNotFound", id); + /// + /// Marker type of the resource catalog that declares . + /// Resource key; must exist in the neutral catalog (enforced by Architecture.Tests). + /// Format arguments for the localized message's {0}, {1}, … placeholders. + /// This exception, so the call chains onto throw new .... + // The setters are private rather than init so this is the single way to attach a key: the + // analyzers reject an init property with a hand-written backing field (S2292), and two + // syntaxes for the same thing would let half the codebase drift back to the long form. + public CustomException Localized(string key, params object[] args) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(args); + MessageKey = key; + MessageArgs = args; + ResourceSource = typeof(TResource); + return this; + } + + /// + /// Localizes the response body from the shared (Core) catalog, for the generic keys every + /// module uses (e.g. Error.NoCurrentUser). + /// + /// Resource key; must exist in the shared neutral catalog (enforced by Architecture.Tests). + /// Format arguments for the localized message's {0}, {1}, … placeholders. + /// This exception, so the call chains onto throw new .... + public CustomException Localized(string key, params object[] args) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(args); + MessageKey = key; + MessageArgs = args; + ResourceSource = null; + return this; + } /// /// Initializes a new instance of the class with default message and internal server error status. diff --git a/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs b/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs index 2a26e7f0a4..4da9ffc25d 100644 --- a/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs +++ b/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs @@ -12,7 +12,7 @@ public class ForbiddenException : CustomException public ForbiddenException() : base("Unauthorized access.", Array.Empty(), HttpStatusCode.Forbidden) { - MessageKey = "Error.ForbiddenAccess"; + Localized("Error.ForbiddenAccess"); } /// diff --git a/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs b/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs index 3acf01fd1d..35323df2a3 100644 --- a/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs +++ b/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs @@ -12,7 +12,7 @@ public class UnauthorizedException : CustomException public UnauthorizedException() : base("Authentication failed.", Array.Empty(), HttpStatusCode.Unauthorized) { - MessageKey = "Error.AuthenticationFailed"; + Localized("Error.AuthenticationFailed"); } /// diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx b/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx index 6bbb10b077..545f6e93d6 100644 --- a/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx +++ b/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx @@ -175,6 +175,9 @@ O número da página deve ser maior ou igual a 1. + + O número da página deve estar entre 1 e {0}. + O tamanho da página deve ser maior ou igual a 1. diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.resx b/src/BuildingBlocks/Core/Localization/SharedResources.resx index 0f49c67bf5..5cca37631d 100644 --- a/src/BuildingBlocks/Core/Localization/SharedResources.resx +++ b/src/BuildingBlocks/Core/Localization/SharedResources.resx @@ -175,6 +175,9 @@ Page number must be greater than or equal to 1. + + Page number must be between 1 and {0}. + Page size must be greater than or equal to 1. diff --git a/src/BuildingBlocks/Jobs/Extensions.cs b/src/BuildingBlocks/Jobs/Extensions.cs index 04206d7aa2..086353bb48 100644 --- a/src/BuildingBlocks/Jobs/Extensions.cs +++ b/src/BuildingBlocks/Jobs/Extensions.cs @@ -32,10 +32,7 @@ public static IServiceCollection AddHeroJobs(this IServiceCollection services) { var configuration = provider.GetRequiredService(); var dbOptions = configuration.GetSection(nameof(DatabaseOptions)).Get() - ?? throw new CustomException("Database options not found") - { - MessageKey = "Jobs.DatabaseOptionsNotFound", - }; + ?? throw new CustomException("Database options not found").Localized("Jobs.DatabaseOptionsNotFound"); switch (dbOptions.Provider.ToUpperInvariant()) { @@ -52,10 +49,7 @@ public static IServiceCollection AddHeroJobs(this IServiceCollection services) default: throw new CustomException($"Hangfire storage provider {dbOptions.Provider} is not supported") - { - MessageKey = "Jobs.UnsupportedStorageProvider", - MessageArgs = [dbOptions.Provider], - }; + .Localized("Jobs.UnsupportedStorageProvider", dbOptions.Provider); } config.UseActivator(new FshJobActivator(provider.GetRequiredService())); diff --git a/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs b/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs index a94cc4f04b..fde84207d1 100644 --- a/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs +++ b/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs @@ -70,10 +70,7 @@ public async Task UploadAsync(FileUploadRequest request, FileType fil $"Storage quota exceeded ({check.CurrentUsage}/{check.Limit} bytes).", errors: null, HttpStatusCode.InsufficientStorage) - { - MessageKey = "Storage.QuotaExceeded", - MessageArgs = [check.CurrentUsage, check.Limit], - }; + .Localized("Storage.QuotaExceeded", check.CurrentUsage, check.Limit); } try diff --git a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs index 56341c56af..9f3ee4c6c3 100644 --- a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs +++ b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs @@ -53,7 +53,7 @@ private string LocalizeDetail(ILocalizableMessage localizable, string fallbackMe { var message = localizable.MessageArgs.Count == 0 ? moduleLocalizer[localizable.MessageKey] - : moduleLocalizer[localizable.MessageKey, localizable.MessageArgs.ToArray()]; + : moduleLocalizer[localizable.MessageKey, LocalizeArguments(localizable.MessageArgs, moduleLocalizer)]; return message.ResourceNotFound ? fallbackMessage : message.Value; } catch (FormatException) @@ -62,6 +62,29 @@ private string LocalizeDetail(ILocalizableMessage localizable, string fallbackMe } } + // An enum argument would otherwise reach the user as its C# member name, leaving a translated + // sentence ending in an English word ("um chamado no status Closed"). Each member is looked up as + // "{EnumType}.{Member}" in the same catalog as the message itself. A member with no entry keeps + // ToString(), which is what every argument does today. + private static object[] LocalizeArguments(IReadOnlyList args, IStringLocalizer localizer) + { + var localized = new object[args.Count]; + for (var i = 0; i < args.Count; i++) + { + if (args[i] is Enum member) + { + var entry = localizer[$"{member.GetType().Name}.{member}"]; + localized[i] = entry.ResourceNotFound ? member.ToString() : entry.Value; + } + else + { + localized[i] = args[i]; + } + } + + return localized; + } + // Writes the localized Detail and, when the exception carries a MessageKey, surfaces that key as a // stable machine-readable "code". Detail is prose under the request culture, so a client that needs // to branch on a specific error (a terminal state, a dedicated screen, a retry) keys off the code diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs index 1850fa904e..ba3eed7e62 100644 --- a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs +++ b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs @@ -64,9 +64,7 @@ public string ResolveForCurrentRequest() "The request origin is not an allowed front-end origin.", errors: null, HttpStatusCode.BadRequest) - { - MessageKey = "Frontend.OriginNotAllowed", - }; + .Localized("Frontend.OriginNotAllowed"); } public string ResolveDefault() diff --git a/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs b/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs index 480541edf8..e6c12dacac 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... /// } /// } @@ -27,21 +29,21 @@ public sealed class PagedQueryValidator : AbstractValidator /// public const int MaxPageNumber = 1_000_000; - public PagedQueryValidator() + public PagedQueryValidator(IStringLocalizer localizer) { RuleFor(q => q.PageNumber) .InclusiveBetween(1, MaxPageNumber) .When(q => q.PageNumber.HasValue) - .WithMessage($"Page number must be between 1 and {MaxPageNumber}."); + .WithMessage(_ => localizer["Validation.PageNumberRange", MaxPageNumber]); 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 39ac673040..0c7bae922c 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -57,40 +57,40 @@ - - - + + + - - - - - - + + + + + + - - - + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -104,24 +104,26 @@ - + - - + + - + diff --git a/src/FSH.Starter.slnx b/src/FSH.Starter.slnx index c8861e8a70..99caf41538 100644 --- a/src/FSH.Starter.slnx +++ b/src/FSH.Starter.slnx @@ -81,6 +81,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..450b34eed6 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,8 @@ 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.") + .Localized("Error.Auditing.CrossTenantSummaryForbidden"); } 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..eed8274541 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,8 @@ 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.") + .Localized("Error.Auditing.CrossTenantAccessForbidden"); } 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..47eab01ef3 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,11 @@ 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.").Localized("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.") + .Localized("Billing.OnlyRootOperatorMayGenerateInvoices"); } 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..ca0ede2c56 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,7 @@ 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.").Localized("Error.TenantContextRequired"); var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var invoice = await dbContext.Invoices.AsNoTracking() @@ -30,7 +31,8 @@ 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.") + .Localized("Billing.InvoiceNotFound", query.InvoiceId); 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..c9d2fcb155 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,7 @@ 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.").Localized("Error.TenantContextRequired"); var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var invoice = await dbContext.Invoices.AsNoTracking() @@ -30,7 +31,8 @@ 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.") + .Localized("Billing.InvoiceNotFound", query.InvoiceId); 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 40c91c178f..b6f2da57cd 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 @@ -23,7 +23,7 @@ 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.").Localized("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 1b59c5960c..0bae67dd96 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 @@ -21,7 +21,7 @@ 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.").Localized("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..1ac93d9378 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,8 @@ 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.") + .Localized("Billing.ActivePlanNotFound", query.PlanKey); 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..573164183c 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,8 @@ 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.") + .Localized("Billing.PlanNotFound", command.PlanId); 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..f693b76cfc 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,7 @@ 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.").Localized("Error.TenantContextRequired"); var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var targetTenantId = isRoot ? command.TenantId : callerTenantId; @@ -29,7 +30,8 @@ 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.") + .Localized("Billing.ActivePlanNotFound", command.PlanKey); 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..01ae0d509d 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,7 @@ 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.").Localized("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..a73e3c0944 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,7 @@ 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.").Localized("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..b1cb94febb 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,7 @@ 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.").Localized("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..55bb36be4b 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,19 @@ 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.").Localized("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.") + .Localized("Billing.TopupRequestNotFound", command.Id); 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.") + .Localized("Billing.CannotApproveTopupForOtherTenant"); } // 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..3d1553e016 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,7 @@ 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.").Localized("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 3dc66e13a9..0edb80bdb0 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 @@ -23,7 +23,7 @@ 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.").Localized("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..6a2ab72ce0 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,7 @@ 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.").Localized("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 0f70c482bf..5ee25d35d0 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 @@ -24,7 +24,7 @@ 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.").Localized("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..99cacc22ee 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,19 @@ 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.").Localized("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.") + .Localized("Billing.TopupRequestNotFound", command.Id); 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.") + .Localized("Billing.CannotRejectTopupForOtherTenant"); } if (request.Status != TopupRequestStatus.Pending) @@ -38,7 +41,8 @@ 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) + .Localized("Billing.TopupRequestCannotBeRejected", command.Id, request.Status); } 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..0c7f7140d9 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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). + + + Não é possível creditar uma recarga em {0} numa carteira em {1}. + + + Pendente + + + Faturada + + + Concluída + + + Rejeitada + + + Cancelada + + 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..84d7dd136d --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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). + + + Cannot credit a {0} top-up to a {1} wallet. + + + Pending + + + Invoiced + + + Completed + + + Rejected + + + Cancelled + + 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..eb48436f25 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,8 @@ 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}.") + .Localized("Billing.PlanNotFoundForTenant", subscription.PlanId, tenantId); var snapshots = await _usageReporter.CaptureForPeriodAsync(tenantId, periodYear, periodMonth, cancellationToken).ConfigureAwait(false); @@ -188,7 +190,8 @@ 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.") + .Localized("Billing.TopupRequestNotFoundOrNotPending", topupRequestId); var now = _timeProvider.GetUtcNow().UtcDateTime; var invoiceNumber = BuildTopupInvoiceNumber(tenantId, now, topupRequestId); @@ -261,7 +264,8 @@ public async Task MarkInvoicePaidAsync(Guid invoiceId, CancellationToken cancell throw new CustomException( $"Cannot credit a {invoice.Currency} top-up to a {wallet.Currency} wallet.", errors: null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Billing.WalletCurrencyMismatch", invoice.Currency, wallet.Currency); } wallet.Credit( @@ -289,13 +293,14 @@ 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.").Localized("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.") + .Localized("Billing.InvoiceNotFound", invoiceId); } public async Task CreateSubscriptionInvoiceAsync( @@ -308,7 +313,8 @@ 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}.") + .Localized("Billing.PlanNotFoundForTenant", planId, tenantId); 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..7b44ebd04e 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,8 @@ public async ValueTask Handle(CreateBrandCommand command, CancellationToke throw new CustomException( $"A brand with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Catalog.BrandNameAlreadyExists", command.Name); } 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..cde313b414 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,8 @@ 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.") + .Localized("Catalog.BrandNotFound", command.BrandId); 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..a7a503079e 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,8 @@ 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.") + .Localized("Catalog.BrandNotFound", query.BrandId); 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..99c2753693 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,8 @@ 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.") + .Localized("Catalog.BrandNotFound", command.BrandId); 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..3b1764137a 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,8 @@ 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.") + .Localized("Catalog.BrandNotFound", command.BrandId); brand.Update(command.Name, command.Description, command.LogoUrl); @@ -29,7 +31,8 @@ public async ValueTask Handle(UpdateBrandCommand command, CancellationToke throw new CustomException( $"Another brand with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Catalog.AnotherBrandNameAlreadyExists", command.Name); } 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..f83fa9c059 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,8 @@ 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.") + .Localized("Catalog.ParentCategoryNotFound", parentId); } } @@ -36,7 +38,8 @@ public async ValueTask Handle(CreateCategoryCommand command, CancellationT throw new CustomException( $"A category with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Catalog.CategoryNameAlreadyExists", command.Name); } 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..debc4240bd 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,8 @@ 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.") + .Localized("Catalog.CategoryNotFound", command.CategoryId); bool hasChildren = await dbContext.Categories .AnyAsync(c => c.ParentCategoryId == category.Id, cancellationToken) @@ -27,7 +29,8 @@ 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) + .Localized("Catalog.CategoryHasChildren"); } 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..8753cf7b38 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,8 @@ 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.") + .Localized("Catalog.CategoryNotFound", query.CategoryId); 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..41c9534a26 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,8 @@ 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.") + .Localized("Catalog.CategoryNotFound", command.CategoryId); 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..854ad49046 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,8 @@ 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.") + .Localized("Catalog.CategoryNotFound", command.CategoryId); if (command.ParentCategoryId is { } parentId) { @@ -26,7 +28,8 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( "A category cannot be its own parent.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + .Localized("Catalog.CategoryCannotBeOwnParent"); } // Walk parent chain to detect cycles (parent → ancestor of self) @@ -39,7 +42,8 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( "Setting this parent would create a cycle.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + .Localized("Catalog.CategoryParentCycle"); } cursor = await dbContext.Categories .Where(c => c.Id == cur) @@ -59,7 +63,8 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( $"Another category with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Catalog.AnotherCategoryNameAlreadyExists", command.Name); } 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..12344234ac 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,8 @@ 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.") + .Localized("Catalog.ProductNotFound", command.ProductId); 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..35593b9c20 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,8 @@ 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.") + .Localized("Catalog.ProductNotFound", command.ProductId); try { @@ -25,7 +27,8 @@ 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) + .Localized("Catalog.StockAdjustmentNegative", command.Delta, product.Stock); } 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..9c825e1bdc 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,8 @@ 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.") + .Localized("Catalog.ProductNotFound", command.ProductId); 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..f89ccc19f5 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,8 @@ 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.") + .Localized("Catalog.BrandNotFound", command.BrandId); } bool categoryExists = await dbContext.Categories @@ -29,7 +31,8 @@ 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.") + .Localized("Catalog.CategoryNotFound", command.CategoryId); } var product = Product.Create( @@ -49,7 +52,8 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo throw new CustomException( $"A product with SKU '{product.Sku}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Catalog.ProductSkuAlreadyExists", product.Sku); } bool slugTaken = await dbContext.Products @@ -60,7 +64,8 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo throw new CustomException( $"A product with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Catalog.ProductNameAlreadyExists", command.Name); } 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..e1dfa23afd 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,8 @@ 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.") + .Localized("Catalog.ProductNotFound", command.ProductId); 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..1d89e8f74e 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,8 @@ 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.") + .Localized("Catalog.ProductNotFound", query.ProductId); 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..bf3dd07cbf 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,14 @@ 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.") + .Localized("Catalog.ProductNotFound", command.ProductId); // 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}.") + .Localized("Catalog.ProductImageNotFound", command.ImageId, command.ProductId); } 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..b326418ec6 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,8 @@ 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.") + .Localized("Catalog.ProductNotFound", command.ProductId); 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..b1b83272c8 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,8 @@ 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.") + .Localized("Catalog.ProductNotFound", command.ProductId); 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..6af7f06e3c 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,15 @@ 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.") + .Localized("Catalog.ProductNotFound", command.ProductId); // 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}.") + .Localized("Catalog.ProductImageNotFound", command.ImageId, command.ProductId); } 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..5b4f5c70a2 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,8 @@ 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.") + .Localized("Catalog.ProductNotFound", command.ProductId); if (product.BrandId != command.BrandId) { @@ -26,7 +28,8 @@ 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.") + .Localized("Catalog.BrandNotFound", command.BrandId); } } @@ -37,7 +40,8 @@ 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.") + .Localized("Catalog.CategoryNotFound", command.CategoryId); } } @@ -56,7 +60,8 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo throw new CustomException( $"Another product with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Catalog.AnotherProductNameAlreadyExists", command.Name); } 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 9d839ddd0c..603683fb57 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..56a8e75d6b 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,19 @@ 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").Localized("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.").Localized("Chat.ChannelNotFound"); // 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.") + .Localized("Chat.OnlyAdminsCanAddMembersToPrivateChannel"); } 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..23ce5e449a 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,11 @@ 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").Localized("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.").Localized("Chat.ChannelNotFound"); 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..601eefc16e 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").Localized("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 204f31463c..e5b8e93568 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 @@ -21,7 +21,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").Localized("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..40f3f044ed 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,14 @@ 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").Localized("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) + .Localized("Chat.CannotDmYourself"); } 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..a74303a879 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,13 @@ 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").Localized("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.").Localized("Chat.ChannelNotFound"); // 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 f5b16ff352..ded95236ed 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 @@ -20,7 +20,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").Localized("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..17c894d963 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,23 @@ 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").Localized("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.").Localized("Chat.ChannelNotFound"); 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.") + .Localized("Chat.MessageNotFoundInChannel"); + } 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..8f61747b35 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,12 @@ 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").Localized("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.").Localized("Chat.ChannelNotFound"); // 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..e9c6d91a7d 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,7 @@ 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.").Localized("Chat.ChannelNotFound"); 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..17b6ca7f46 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,11 @@ 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").Localized("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.").Localized("Chat.ChannelNotFound"); 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..77379170ff 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,8 @@ 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.") + .Localized("Chat.ChannelNotFound"); } public static ChannelMember RequireAdmin(this ChatChannel channel, string userId) @@ -22,7 +24,8 @@ 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.") + .Localized("Chat.ChannelAdminRoleRequired"); } 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..cd1be95b36 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,16 @@ 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").Localized("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.").Localized("Chat.MessageNotFound"); 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.").Localized("Chat.MessageNotFound"); 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..9fd859cd5f 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,17 @@ 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").Localized("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.").Localized("Chat.MessageNotFound"); // 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.").Localized("Chat.MessageNotFound"); 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..9507b27627 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,12 @@ 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").Localized("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.").Localized("Chat.ChannelNotFound"); 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..0c823c0639 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,12 @@ 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").Localized("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.").Localized("Chat.ChannelNotFound"); 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..fc9901f430 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").Localized("Error.NoCurrentUser"); var currentUserId = userId.ToString(); // Load the parent so we can authorize the caller through the channel. @@ -31,11 +32,13 @@ 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.") + .Localized("Chat.ParentMessageNotFound"); 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.") + .Localized("Chat.ParentMessageNotFound"); 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..cc1f003a6b 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,16 @@ 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").Localized("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.").Localized("Chat.MessageNotFound"); 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.").Localized("Chat.MessageNotFound"); 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..048836aa6b 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,12 @@ 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").Localized("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.").Localized("Chat.ChannelNotFound"); channel.RequireMember(currentUserId); @@ -43,15 +44,18 @@ 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.") + .Localized("Chat.ParentMessageNotFound"); 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) + .Localized("Chat.ParentMessageDifferentChannel"); } 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) + .Localized("Chat.CannotReplyToReply"); } } 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..8d58b47c0e 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,16 @@ 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").Localized("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.").Localized("Chat.MessageNotFound"); 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.").Localized("Chat.MessageNotFound"); 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..2ac1289304 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,17 @@ 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").Localized("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.").Localized("Chat.MessageNotFound"); // 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.").Localized("Chat.MessageNotFound"); 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..1898e37bf5 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,16 @@ 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").Localized("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.").Localized("Chat.MessageNotFound"); 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.").Localized("Chat.MessageNotFound"); 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 c61d040e52..dbdd96ccb4 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs @@ -24,7 +24,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").Localized("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 6e1942e2d4..1e3c9bad75 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,8 @@ public void MarkAvailable(long actualSize, ScanStatus scanResult) throw new CustomException( $"Cannot finalize file in status {Status}.", errors: null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Files.CannotFinalizeInStatus", Status); } if (actualSize <= 0) { @@ -126,7 +128,8 @@ public void ChangeVisibility(Visibility next) throw new CustomException( $"Cannot change visibility while file is in status {Status}.", errors: null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Files.CannotChangeVisibilityInStatus", Status); } 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 f6541d139b..1cae25e736 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; @@ -30,22 +31,24 @@ 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) + .Localized("Files.UnknownVisibility", cmd.Visibility); } var f = await db.FileAssets .AsNoTracking() .FirstOrDefaultAsync(x => x.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found").Localized("Files.FileNotFound"); var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new ForbiddenException("no policy"); + ?? throw new ForbiddenException("no policy").Localized("Files.NoAccessPolicy"); 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") + .Localized("Files.NotAllowedToChangeVisibility"); } // Visibility lives in the storage key (public/ vs private/), so a flip moves the object: bucket @@ -56,13 +59,13 @@ public async ValueTask Handle(ChangeFileVisibilityCommand cmd, Can { if (asset.IsDeleted) { - throw new NotFoundException("file not found"); + throw new NotFoundException("file not found").Localized("Files.FileNotFound"); } asset.ChangeVisibility(cmd.Visibility); return true; }, cancellationToken).ConfigureAwait(false); - f = changed ?? throw new NotFoundException("file not found"); + f = changed ?? throw new NotFoundException("file not found").Localized("Files.FileNotFound"); var publicUrl = f.Visibility == Visibility.Public ? storage.BuildPublicUrl(f.StorageKey) 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..880c93aafa 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,16 @@ 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").Localized("Files.FileNotFound"); var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new ForbiddenException("no policy"); + ?? throw new ForbiddenException("no policy").Localized("Files.NoAccessPolicy"); 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") + .Localized("Files.NotAllowedToDelete"); } // 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 7b0ed9a85e..e7ef1432b2 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; @@ -31,25 +32,28 @@ 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") + .Localized("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").Localized("Files.FileNotFound"); if (!string.Equals(asset.CreatedByUserId, userId, StringComparison.Ordinal)) { - throw new ForbiddenException("not your pending file"); + throw new ForbiddenException("not your pending file").Localized("Files.NotYourPendingFile"); } 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) + .Localized("Files.AlreadyFinalized"); } 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) + .Localized("Files.UploadNotReceived"); // Allow declared+1% slack (S3 may differ slightly on multipart). Reject larger sizes. var maxAllowed = asset.SizeBytes + Math.Max(1024L, asset.SizeBytes / 100); @@ -61,7 +65,8 @@ public async ValueTask Handle(FinalizeUploadCommand cmd, Cancellat throw new CustomException( $"uploaded size ({head.SizeBytes}) exceeds declared ({asset.SizeBytes})", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + .Localized("Files.UploadedSizeExceedsDeclared", head.SizeBytes, asset.SizeBytes); } if (!string.Equals(head.ContentType, asset.ContentType, StringComparison.OrdinalIgnoreCase)) @@ -72,7 +77,8 @@ public async ValueTask Handle(FinalizeUploadCommand cmd, Cancellat throw new CustomException( "uploaded content-type mismatch", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + .Localized("Files.ContentTypeMismatch"); } 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..6873bbb41d 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,16 @@ 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").Localized("Files.FileNotFound"); var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found").Localized("Files.FileNotFound"); 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").Localized("Files.FileNotFound"); } 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..8eaf0b6878 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,17 @@ 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").Localized("Files.FileNotFound"); 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").Localized("Files.FileNotFound"); 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").Localized("Files.FileNotFound"); } // 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 4f328a7e84..53d11d18d9 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs @@ -25,7 +25,7 @@ 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").Localized("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 ca08c78856..30e00a7d8a 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,19 @@ 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") + .Localized("Error.InvalidTenant"); var userId = currentUser.GetUserId(); if (userId == Guid.Empty) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user").Localized("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) + .Localized("Files.UnknownCategory", cmd.Category); } var extension = Path.GetExtension(cmd.FileName); @@ -48,7 +51,8 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"Extension '{extension}' not allowed for category '{cmd.Category}'.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + .Localized("Files.ExtensionNotAllowed", extension, cmd.Category); } if (cmd.SizeBytes > category.MaxBytes) @@ -56,15 +60,18 @@ 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) + .Localized("Files.FileExceedsMaxSize", category.MaxBytes, cmd.Category); } // 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}'.") + .Localized("Files.NoPolicyForOwnerType", cmd.OwnerType); 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.") + .Localized("Files.NotAllowedToAttach"); } // Quota pre-check (no debit yet — debit happens on finalize with actual bytes). @@ -74,7 +81,8 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"Storage quota exceeded ({quotaCheck.CurrentUsage}/{quotaCheck.Limit} bytes).", (IEnumerable?)null, - (HttpStatusCode)507); + (HttpStatusCode)507) + .Localized("Files.StorageQuotaExceeded", quotaCheck.CurrentUsage, quotaCheck.Limit); } // 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..26a21112d4 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,7 @@ 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").Localized("Files.FileNotFound"); 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..630d451323 --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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. + + + Aguardando envio + + + Disponível + + + Em quarentena + + + Público + + + Privado + + 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..f4db13b156 --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.resx @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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. + + + Pending upload + + + Available + + + Quarantined + + + Public + + + Private + + diff --git a/src/Modules/Files/Modules.Files/Modules.Files.csproj b/src/Modules/Files/Modules.Files/Modules.Files.csproj index cd1ea60036..f2e03552f7 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..1dec0e1aa3 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,8 @@ 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.") + .Localized("Identity.GroupNotFound", command.GroupId); } // Validate user IDs exist @@ -44,7 +46,8 @@ 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)}") + .Localized("Identity.UsersNotFound", string.Join(", ", invalidUserIds)); } // 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..dfd001ce53 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,8 @@ 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) + .Localized("Identity.GroupNameAlreadyExists", command.Name); } // Validate role IDs exist — fetch Id+Name in a single query to avoid a second roundtrip later @@ -46,7 +48,8 @@ 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)}") + .Localized("Identity.RolesNotFoundWithIds", string.Join(", ", invalidRoleIds)); } } 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..e242f11896 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,13 @@ 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.") + .Localized("Identity.GroupNotFound", command.Id); if (group.IsSystemGroup) { - throw new ForbiddenException("System groups cannot be deleted."); + throw new ForbiddenException("System groups cannot be deleted.") + .Localized("Identity.SystemGroupsCannotBeDeleted"); } // 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..aeb8e98ac4 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,8 @@ 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.") + .Localized("Identity.GroupNotFound", query.Id); 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..f6324bd28e 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,8 @@ 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.") + .Localized("Identity.GroupNotFound", query.GroupId); } // 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..807ab638b2 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,16 @@ 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}'.") + .Localized("Identity.UserNotMemberOfGroup", command.UserId, command.GroupId); } // 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.") + .Localized("Identity.CannotRemoveFromDefaultGroup"); } _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..dda14a807f 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,8 @@ 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.") + .Localized("Identity.SystemGroupsCannotBeModified"); } await ValidateUniqueNameAsync(command.Id, command.Name, cancellationToken); @@ -69,7 +71,8 @@ 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.") + .Localized("Identity.GroupNotFound", id); } private async Task ValidateUniqueNameAsync(Guid excludeId, string name, CancellationToken cancellationToken) @@ -79,7 +82,8 @@ 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) + .Localized("Identity.GroupNameAlreadyExists", name); } } @@ -98,7 +102,8 @@ 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)}") + .Localized("Identity.RolesNotFoundWithIds", string.Join(", ", invalidRoleIds)); } } 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..34be928109 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,8 @@ public async ValueTask Handle( throw new CustomException( "current session is not an impersonation session", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + .Localized("Identity.NotAnImpersonationSession"); } var impersonatedUserId = _currentUser.GetUserId().ToString(); @@ -93,7 +95,8 @@ public async ValueTask Handle( if (actorClaimsResult is null) { - throw new NotFoundException("original actor not found"); + throw new NotFoundException("original actor not found") + .Localized("Identity.OriginalActorNotFound"); } 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..ded59216f0 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,7 @@ public async ValueTask> Handle( ArgumentNullException.ThrowIfNull(request); var callerTenant = currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context"); + ?? throw new UnauthorizedException("missing tenant context").Localized("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..5c1fd5aa77 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,22 @@ 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").Localized("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") + .Localized("Identity.ImpersonationGrantNotFound"); 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") + .Localized("Identity.ImpersonationGrantNotFound"); } var updated = await grantService.RevokeAsync( diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs index 4876de715f..2fb9c071d1 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs @@ -59,10 +59,7 @@ public async ValueTask Handle( var actorUserId = _currentUser.GetUserId().ToString(); var actorTenantId = _currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context") - { - MessageKey = "Error.InvalidTenant", - }; + ?? throw new UnauthorizedException("missing tenant context").Localized("Error.InvalidTenant"); var actorUserName = _currentUser.Name; // Cross-tenant impersonation requires the actor to be in the root tenant. Tenant admins @@ -71,10 +68,7 @@ public async ValueTask Handle( && !string.Equals(actorTenantId, request.TargetTenantId, StringComparison.Ordinal)) { throw new ForbiddenException("cross-tenant impersonation is restricted to platform operators") - { - MessageKey = "Identity.CrossTenantImpersonationRestricted", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.CrossTenantImpersonationRestricted"); } // Prevent self-impersonation (pointless, confuses the audit trail). Caller error → explicit 4xx, @@ -83,10 +77,7 @@ public async ValueTask Handle( && string.Equals(actorTenantId, request.TargetTenantId, StringComparison.Ordinal)) { throw new CustomException("cannot impersonate yourself", errors: null, System.Net.HttpStatusCode.BadRequest) - { - MessageKey = "Identity.CannotImpersonateYourself", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.CannotImpersonateYourself"); } // Prevent nesting: if the caller is already impersonating, require end-impersonation first. @@ -98,10 +89,7 @@ public async ValueTask Handle( "end current impersonation before starting a new one", errors: null, System.Net.HttpStatusCode.BadRequest) - { - MessageKey = "Identity.EndImpersonationFirst", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.EndImpersonationFirst"); } var targetClaimsResult = await _identityService @@ -110,10 +98,7 @@ public async ValueTask Handle( if (targetClaimsResult is null) { throw new NotFoundException("target user not found") - { - MessageKey = "Identity.TargetUserNotFound", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.TargetUserNotFound"); } var (subject, claims) = targetClaimsResult.Value; 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 793ecca4f8..37e773ca3e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs @@ -10,6 +10,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; @@ -58,10 +59,12 @@ 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.") + .Localized("Identity.RoleManagerNotResolved"); 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.") + .Localized("Identity.RoleStoreNotConfigured"); var page = Math.Max(1, pageNumber); var size = Math.Clamp(pageSize, 1, 200); @@ -98,7 +101,7 @@ public async Task> GetRolesAsync( { FshRole? role = await roleManager.FindByIdAsync(id); - _ = role ?? throw new NotFoundException("role not found"); + _ = role ?? throw new NotFoundException("role not found").Localized("Identity.RoleNotFound"); return new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }; } @@ -112,9 +115,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; @@ -123,7 +126,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); @@ -136,9 +139,9 @@ 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").Localized("Identity.RoleNotFound"); - 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. @@ -150,7 +153,7 @@ 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").Localized("Identity.RoleNotFound"); role.Permissions = await context.RoleClaims .AsNoTracking() @@ -166,9 +169,9 @@ 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").Localized("Identity.RoleNotFound"); - 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); @@ -182,11 +185,12 @@ 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) + .Localized(messageKey); } } @@ -215,7 +219,8 @@ private async Task RemoveRevokedPermissionsAsync(FshRole role, IList error.Description).ToList(); - throw new CustomException("operation failed", errors); + throw new CustomException("operation failed", errors) + .Localized("Identity.OperationFailed"); } } } 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 41c5878ade..e107e0c7ab 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,8 @@ 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.") + .Localized("Identity.InvalidRefreshToken"); } var (subject, claims) = validated.Value; @@ -62,7 +64,8 @@ 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.") + .Localized("Identity.SessionRevoked"); } // Optionally, cross-check the provided access token subject @@ -87,7 +90,8 @@ 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.") + .Localized("Identity.AccessTokenSubjectMismatch"); } } 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 a5a44a43ab..28c30515f8 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..bf631a16c3 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,15 @@ 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.") + .Localized("Identity.UserNotFoundById", userId); // 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.") + .Localized("Identity.CurrentPasswordIncorrect"); } 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..8d4f1efdbb 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,15 @@ 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.") + .Localized("Identity.UserNotFoundById", userId); // 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.") + .Localized("Identity.FailedToGenerateAuthenticatorKey"); 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..dcbdff5e93 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,8 @@ 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.") + .Localized("Identity.UserNotFoundById", userId); var sanitized = command.Code.Replace(" ", string.Empty, StringComparison.Ordinal); var valid = await _userManager.VerifyTwoFactorTokenAsync( @@ -44,7 +46,8 @@ public async ValueTask Handle( throw new CustomException( "The authenticator code is invalid.", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + .Localized("Identity.AuthenticatorCodeInvalid"); } 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..bc59f4b620 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,8 @@ 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.") + .Localized("Identity.UserNotFoundById", query.UserId); } // 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..2f2f9ca6cd 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,7 @@ 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").Localized("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..35c3da6e13 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,8 @@ 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") + .Localized("Identity.InScopeInitializationOnly"); } _user = user; @@ -52,7 +54,8 @@ 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") + .Localized("Identity.InScopeInitializationOnly"); } if (!string.IsNullOrEmpty(userId)) diff --git a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs index 76e07c0ac0..907ba29d98 100644 --- a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs @@ -74,10 +74,7 @@ private async Task VerifyTwoFactorOrThrowAsync(FshUser user, string? twoFactorCo "two_factor_required: An authenticator code is required to complete sign-in.", errors: null, HttpStatusCode.Unauthorized) - { - MessageKey = "Identity.TwoFactorRequired", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.TwoFactorRequired"); } var valid = await _userManager.VerifyTwoFactorTokenAsync( @@ -89,10 +86,7 @@ private async Task VerifyTwoFactorOrThrowAsync(FshUser user, string? twoFactorCo { _logger.LogWarning("Invalid two-factor code for user {UserId}", user.Id); throw new UnauthorizedException("two_factor_invalid: The authenticator code is invalid or expired.") - { - MessageKey = "Identity.TwoFactorInvalid", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.TwoFactorInvalid"); } } @@ -125,11 +119,7 @@ public async Task StoreRefreshTokenAsync(string subject, string refreshToken, Da if (updated == 0) { - throw new UnauthorizedException("user not found") - { - MessageKey = "Identity.UserNotFound", - ResourceSource = typeof(IdentityResources), - }; + throw new UnauthorizedException("user not found").Localized("Identity.UserNotFound"); } if (_logger.IsEnabled(LogLevel.Debug)) @@ -213,10 +203,7 @@ private async Task FindAndValidateUserByCredentialsAsync(string email, "Account is temporarily locked due to too many failed login attempts. Try again later.", errors: null, HttpStatusCode.Locked) - { - MessageKey = "Identity.AccountLocked", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.AccountLocked"); } if (!await _userManager.CheckPasswordAsync(user, password)) @@ -261,10 +248,7 @@ private async Task FindUserByRefreshTokenAsync(string refreshToken, str { _logger.LogWarning("No user found with matching refresh token hash for tenant {TenantId}", tenantId); throw new UnauthorizedException("refresh token is invalid or expired") - { - MessageKey = "Identity.RefreshTokenInvalidOrExpired", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.RefreshTokenInvalidOrExpired"); } return user; @@ -279,10 +263,7 @@ private void ValidateRefreshTokenExpiry(FshUser user) "Refresh token expired for user {UserId}. Expired at: {ExpiryTime}, Current time: {CurrentTime}", user.Id, user.RefreshTokenExpiryTime, now); throw new UnauthorizedException("refresh token is invalid or expired") - { - MessageKey = "Identity.RefreshTokenInvalidOrExpired", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.RefreshTokenInvalidOrExpired"); } } @@ -291,19 +272,13 @@ private static void ValidateUserStatus(FshUser user) if (!user.IsActive) { throw new UnauthorizedException("user is deactivated") - { - MessageKey = "Identity.UserDeactivated", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.UserDeactivated"); } if (!user.EmailConfirmed) { throw new UnauthorizedException("email not confirmed") - { - MessageKey = "Identity.EmailNotConfirmed", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.EmailNotConfirmed"); } } @@ -317,11 +292,7 @@ private void ValidateTenantStatus(AppTenantInfo tenant) if (!tenant.IsActive) { throw new UnauthorizedException($"tenant {tenant.Id} is deactivated") - { - MessageKey = "Identity.TenantDeactivated", - MessageArgs = [tenant.Id], - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.TenantDeactivated", tenant.Id); } // Honor the billing grace period: a lapsed tenant can still authenticate until @@ -329,11 +300,7 @@ private void ValidateTenantStatus(AppTenantInfo tenant) if (_timeProvider.GetUtcNow().UtcDateTime > tenant.ValidUpto.AddDays(_gracePeriodDays)) { throw new UnauthorizedException($"tenant {tenant.Id} validity has expired") - { - MessageKey = "Identity.TenantValidityExpired", - MessageArgs = [tenant.Id], - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.TenantValidityExpired", tenant.Id); } } diff --git a/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs b/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs index 6ed5ebef65..91f3f486e8 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,8 @@ 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") + .Localized("Identity.ImpersonationGrantNotFound"); 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..b648c0544e 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,7 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(_multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("Invalid tenant"); + throw new UnauthorizedException("Invalid tenant").Localized("Error.InvalidTenant"); } } @@ -88,7 +89,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 +201,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 +233,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 528da410a4..14aec6de69 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; @@ -73,7 +74,7 @@ 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").Localized("Identity.UserNotFound"); } token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)); @@ -82,7 +83,8 @@ 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) + .Localized("Identity.ErrorResettingPassword"); } // Raise domain event for password reset @@ -95,14 +97,15 @@ 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").Localized("Identity.UserNotFound"); 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) + .Localized("Identity.FailedToChangePassword"); } // Raise domain event for password change @@ -121,7 +124,7 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant").Localized("Error.InvalidTenant"); } } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs index 39f8073487..5deb81334f 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs @@ -34,11 +34,7 @@ public async Task GetAsync(string userId, CancellationToken cancellatio .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new NotFoundException("user not found") - { - MessageKey = "Identity.UserNotFound", - ResourceSource = typeof(IdentityResources), - }; + _ = user ?? throw new NotFoundException("user not found").Localized("Identity.UserNotFound"); return new UserDto { @@ -85,11 +81,7 @@ public async Task UpdateAsync(string userId, string firstName, string lastName, { var user = await userManager.FindByIdAsync(userId); - _ = user ?? throw new NotFoundException("user not found") - { - MessageKey = "Identity.UserNotFound", - ResourceSource = typeof(IdentityResources), - }; + _ = user ?? throw new NotFoundException("user not found").Localized("Identity.UserNotFound"); // This is a full-representation update, so a caller working from a stale read would // silently blank whatever changed since. The precondition is checked here, before the @@ -155,10 +147,7 @@ public async Task UpdateAsync(string userId, string firstName, string lastName, } throw new CustomException("Update profile failed") - { - MessageKey = "Identity.UpdateProfileFailed", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.UpdateProfileFailed"); } if (replacedBlob is not null) @@ -199,24 +188,17 @@ private static string ComputeProfileVersion(FshUser user) => new[] { user.FirstName, user.LastName, user.PhoneNumber, user.ImageUrl?.OriginalString, user.Locale }))); private static CustomException StaleProfileException() => - new( + new CustomException( "The profile changed since you loaded it. Reload it and apply your changes again.", errors: null, HttpStatusCode.PreconditionFailed) - { - MessageKey = "Identity.ProfileChangedSinceLoaded", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.ProfileChangedSinceLoaded"); public async Task SetImageUrlAsync(string userId, string? imageUrl, CancellationToken cancellationToken) { EnsureValidTenant(); var user = await userManager.FindByIdAsync(userId) - ?? throw new NotFoundException("user not found") - { - MessageKey = "Identity.UserNotFound", - ResourceSource = typeof(IdentityResources), - }; + ?? throw new NotFoundException("user not found").Localized("Identity.UserNotFound"); user.ImageUrl = string.IsNullOrWhiteSpace(imageUrl) ? null @@ -226,10 +208,7 @@ public async Task SetImageUrlAsync(string userId, string? imageUrl, Cancellation if (!result.Succeeded) { throw new CustomException("Update profile image failed") - { - MessageKey = "Identity.UpdateProfileImageFailed", - ResourceSource = typeof(IdentityResources), - }; + .Localized("Identity.UpdateProfileImageFailed"); } await signInManager.RefreshSignInAsync(user); @@ -257,10 +236,7 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant") - { - MessageKey = "Error.InvalidTenant", - }; + throw new UnauthorizedException("invalid tenant").Localized("Error.InvalidTenant"); } } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs index 7827eec646..e3163404f4 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs @@ -11,9 +11,11 @@ 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; +using Microsoft.Extensions.Localization; using System.Collections.ObjectModel; using System.Globalization; using System.Net; @@ -28,7 +30,8 @@ internal sealed class UserRegistrationService( IJobService jobService, IMailService mailService, IMultiTenantContextAccessor multiTenantContextAccessor, - IOutboxStore outboxStore) : IUserRegistrationService + IOutboxStore outboxStore, + IStringLocalizer localizer) : IUserRegistrationService { public async Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default) { @@ -79,14 +82,18 @@ 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.") + .Localized("Identity.EmailConfirmationError"); 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)); + // The success text is what the confirm-email page shows the user, so it localizes like the + // failure below; the old developer hint about /api/tokens is gone from the user's screen. + ? localizer["Identity.EmailConfirmed", user.Email!].Value + : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email)) + .Localized("Identity.EmailConfirmationFailedFor", user.Email!); } public async Task AdminConfirmEmailAsync(string userId, CancellationToken cancellationToken = default) @@ -96,7 +103,8 @@ 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.") + .Localized("Identity.UserNotFoundById", userId); // Idempotent: a second confirm is a no-op rather than an error. if (user.EmailConfirmed) @@ -112,7 +120,8 @@ 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)))) + .Localized("Identity.EmailConfirmationFailedWithErrors", user.Email!, string.Join("; ", result.Errors.Select(e => e.Description))); } } @@ -123,14 +132,16 @@ 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.") + .Localized("Identity.UserNotFoundById", userId); if (user.EmailConfirmed) { throw new CustomException(string.Format( CultureInfo.InvariantCulture, "The email for {0} is already confirmed.", - user.Email)); + user.Email)) + .Localized("Identity.EmailAlreadyConfirmed", user.Email!); } await SendConfirmationEmailAsync(user, origin, cancellationToken); @@ -144,21 +155,23 @@ 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.") + .Localized("Identity.PhoneConfirmationError"); 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)) + .Localized("Identity.PhoneConfirmationFailedFor", user.PhoneNumber!); } private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant").Localized("Error.InvalidTenant"); } } @@ -166,7 +179,8 @@ 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.") + .Localized("Identity.EmailClaimRequired"); } private async Task CreateUserFromPrincipalAsync(ClaimsPrincipal principal, string email) @@ -193,7 +207,8 @@ private async Task CreateUserFromPrincipalAsync(ClaimsPrincipal princip throw new CustomException( "Failed to create user from external principal.", errors, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + .Localized("Identity.FailedToCreateUserFromPrincipal"); } return user; @@ -233,7 +248,8 @@ private static void ValidatePasswordMatch(string password, string confirmPasswor throw new CustomException( "Passwords do not match.", errors: null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + .Localized("Identity.PasswordsDoNotMatch"); } } @@ -267,7 +283,8 @@ private async Task CreateUserWithPasswordAsync( throw new CustomException( "Unable to register the user.", errors, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + .Localized("Identity.UnableToRegisterUser"); } return user; diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs index 52f55a60a3..186950882f 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,7 @@ 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").Localized("Identity.UserNotFound"); await ValidateAdminRoleChangeAsync(user, userRoles); @@ -44,10 +45,10 @@ 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").Localized("Identity.UserNotFound"); var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken) - ?? throw new NotFoundException("roles not found"); + ?? throw new NotFoundException("roles not found").Localized("Identity.RolesNotFound"); // Single membership query instead of one IsInRoleAsync round-trip per role. var memberships = await userManager.GetRolesAsync(user); @@ -90,13 +91,15 @@ private async Task ValidateAdminRoleChangeAsync(FshUser user, List throw new CustomException( "Administrators cannot remove their own admin role.", Array.Empty(), - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + .Localized("Identity.AdminCannotRemoveOwnRole"); } // 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.") + .Localized("Identity.RootAdminCannotBeDemoted"); } // After this removal, at least one admin must remain in the tenant — matches @@ -118,7 +121,8 @@ private async Task EnsureMinimumAdminCountAsync() throw new CustomException( "Tenant must retain at least one administrator.", Array.Empty(), - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + .Localized("Identity.TenantMustRetainOneAdmin"); } } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs index f0e12c4829..f3b0542ddb 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,7 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant").Localized("Error.InvalidTenant"); } } @@ -52,16 +53,17 @@ 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") + .Localized("Error.NoCurrentUser"); } var actor = await userManager.FindByIdAsync(actorId.ToString()) - ?? throw new UnauthorizedException("current user not found"); + ?? throw new UnauthorizedException("current user not found").Localized("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.").Localized("Identity.UserNotFound"); return new ToggleStatusContext( ActorId: actorId, @@ -78,19 +80,22 @@ 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.") + .Localized("Identity.OnlyAdminsCanChangeStatus"); } 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) + .Localized("Identity.CannotDeactivateSelf"); } 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) + .Localized("Identity.AdminsCannotBeDeactivated"); } if (!context.ActivateUser) @@ -107,7 +112,8 @@ 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) + .Localized("Identity.TenantMustHaveActiveAdmin"); } } @@ -130,7 +136,8 @@ 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) + .Localized("Identity.ToggleStatusFailed"); } 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 d7dad9e8db..a8b8e82189 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..3f32483d63 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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. + + + Pendente + + + Em execução + + + Concluído + + + Falhou + + 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..1107f7d245 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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. + + + Pending + + + Running + + + Completed + + + Failed + + 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 fb290123da..7280587aa7 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; @@ -183,7 +184,8 @@ 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.") + .Localized("Multitenancy.TenantDeactivated"); } // Expiry is enforced on every request (not just at login) with a grace period: @@ -194,7 +196,8 @@ 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.") + .Localized("Multitenancy.TenantSubscriptionExpired"); } // 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..b60326a06a 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,8 @@ 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.") + .Localized("Multitenancy.TenantNotFoundDuringProvisioning", tenantId); 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 0d99b4d3c9..31e7f5e2bc 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,14 @@ 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.") + .Localized("Multitenancy.TenantNotFoundForProvisioning", tenantId); 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}.") + .Localized("Multitenancy.ProvisioningAlreadyRunning", tenantId); } var correlationId = Guid.NewGuid().ToString(); @@ -86,7 +89,8 @@ 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}.") + .Localized("Multitenancy.ProvisioningNotFound", tenantId); return ToDto(provisioning); } @@ -101,7 +105,8 @@ 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}.") + .Localized("Multitenancy.TenantNotProvisioned", tenantId, provisioning.Status); } } @@ -172,7 +177,8 @@ 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.") + .Localized("Multitenancy.ProvisioningCorrelationNotFound", correlationId, tenantId); } 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 3b504f5c37..f21dd1e3c1 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,8 @@ 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") + .Localized("Multitenancy.TenantAlreadyActivated", id); } await _provisioningService.EnsureCanActivateAsync(id, cancellationToken).ConfigureAwait(false); @@ -123,18 +125,21 @@ 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") + .Localized("Multitenancy.TenantAlreadyDeactivated", id); } 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.") + .Localized("Multitenancy.AtLeastOneActiveTenantRequired"); } 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.") + .Localized("Multitenancy.RootTenantCannotBeDeactivated"); } tenant.Deactivate(); @@ -247,7 +252,8 @@ 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.") + .Localized("Multitenancy.TenantNotFound", id); // 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..23a4a54a33 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,8 @@ 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") + .Localized("Multitenancy.OnlyRootCanSetDefaultTheme"); } // Clear existing default @@ -240,7 +242,8 @@ 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") + .Localized("Multitenancy.TenantThemeNotFound", tenantId); } 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..eb0440871f 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,10 @@ 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").Localized("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 e3dafb230b..d4690863cb 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs @@ -20,7 +20,10 @@ 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").Localized("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..a45965cd50 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,10 @@ 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").Localized("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..be0bdb9c80 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,10 @@ 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").Localized("Error.NoCurrentUser"); + } var currentUserId = userId.ToString(); // Caller-scoped: filter by (Id, UserId) so users can only mutate their own rows. Returns @@ -24,7 +28,8 @@ 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.") + .Localized("Notifications.NotificationNotFound"); 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 c88a0a2ff6..4f6048df06 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; using FSH.Framework.Mailing; 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 500b34ef04..198d7269d2 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..1c4135893c 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; @@ -87,7 +88,7 @@ public static Ticket Create( public void Assign(Guid? assigneeUserId) { - ThrowIfClosedOrResolved("assign"); + ThrowIfNotAssignable(); if (assigneeUserId == AssignedToUserId) { @@ -120,7 +121,8 @@ public void Resolve(string? resolutionNote) throw new CustomException( "A closed ticket cannot be resolved — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Tickets.ClosedCannotResolve"); } if (Status == TicketStatus.Resolved) { @@ -148,7 +150,8 @@ 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) + .Localized("Tickets.OnlyResolvedCanClose", Status); } ClosedAtUtc = DateTime.UtcNow; @@ -168,7 +171,8 @@ 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) + .Localized("Tickets.ClosedCannotEdit"); } Title = title.Trim(); @@ -201,7 +205,8 @@ 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) + .Localized("Tickets.ClosedCannotComment"); } var comment = TicketComment.Create(Id, authorUserId, body); @@ -227,14 +232,15 @@ private void TransitionStatus(TicketStatus next) (id, ts) => new TicketStatusChangedDomainEvent(Id, previous, next, id, ts))); } - private void ThrowIfClosedOrResolved(string action) + private void ThrowIfNotAssignable() { if (Status is TicketStatus.Closed or TicketStatus.Resolved) { throw new CustomException( - $"Cannot {action} a ticket in status {Status} — reopen it first.", + $"Cannot assign a ticket in status {Status} — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + .Localized("Tickets.CannotAssignInStatus", Status); } } } 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..e2d376a4b1 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,8 @@ public async ValueTask Handle(AddTicketCommentCommand command, Cancellatio throw new CustomException( "Cannot post a comment without an authenticated author.", (IEnumerable?)null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + .Localized("Tickets.CommentAuthorRequired"); } // Load the Comments collection up front so EF's change tracker detects the new TicketComment @@ -32,7 +34,8 @@ 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.") + .Localized("Tickets.TicketNotFound", command.TicketId); 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..e56ac1e1a6 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,8 @@ 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.") + .Localized("Tickets.TicketNotFound", command.TicketId); 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..72be050158 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,8 @@ 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.") + .Localized("Tickets.TicketNotFound", command.TicketId); 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..52cbd43aeb 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,8 @@ public async ValueTask Handle(CreateTicketCommand command, CancellationTok throw new CustomException( "Cannot create a ticket without an authenticated reporter.", (IEnumerable?)null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + .Localized("Tickets.ReporterRequired"); } // 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..d20a08192d 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,8 @@ 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.") + .Localized("Tickets.TicketNotFound", command.TicketId); // 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..c1a6b393b1 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,8 @@ 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.") + .Localized("Tickets.TicketNotFound", query.TicketId); } 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..09f7cf2e72 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,8 @@ public async ValueTask> Handle( .ConfigureAwait(false); if (!ticketExists) { - throw new NotFoundException($"Ticket {query.TicketId} not found."); + throw new NotFoundException($"Ticket {query.TicketId} not found.") + .Localized("Tickets.TicketNotFound", query.TicketId); } 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..5258928b8a 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,8 @@ 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.") + .Localized("Tickets.TicketNotFound", command.TicketId); 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..7b786f4756 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,8 @@ 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.") + .Localized("Tickets.TicketNotFound", command.TicketId); 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..b0b531a2ae 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,8 @@ 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.") + .Localized("Tickets.TicketNotFound", command.TicketId); 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..389a5144b3 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,8 @@ 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.") + .Localized("Tickets.TicketNotFound", command.TicketId); 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..22bdf540b2 --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 atribuir um chamado no status {0}. 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. + + + Aberto + + + Em andamento + + + Resolvido + + + Fechado + + 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..8ccde88de7 --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 assign a ticket in status {0} — reopen it first. + + + Cannot post a comment without an authenticated author. + + + Cannot create a ticket without an authenticated reporter. + + + Open + + + In progress + + + Resolved + + + Closed + + 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..fdd26ba00a 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,8 @@ 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.") + .Localized("Webhooks.SubscriptionNotFound", command.Id); 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..038ebdd942 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,8 @@ 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.") + .Localized("Webhooks.SubscriptionNotFound", command.Id); 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..93a0b06d74 --- /dev/null +++ b/src/Tests/Architecture.Tests/CatalogParityTests.cs @@ -0,0 +1,175 @@ +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. This closes that class of gap: a new module catalog is covered the moment +/// its assembly lands in the output, with no new test to write. +/// +/// A translation is optional: a key missing from a translated catalog falls back to the +/// neutral (English) string at runtime, so a contributor never has to write Portuguese to +/// get a green build. What fails is a translated key the neutral catalog does not declare +/// (an orphan left by a rename or delete, which never resolves) and a translation whose +/// `{n}` placeholders differ from the neutral string. +/// +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. + /// + internal 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, every neutral key would appear in pt-BR too, and its English + /// value would be compared against itself in the placeholder check. + /// + internal 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. + /// + internal 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_Translated_Catalog_Stays_Consistent_With_Its_Neutral_Catalog() + { + 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; + + // A catalog nobody has translated yet is the same case as a missing key, one level up. + var translated = OwnEntries(manager, new CultureInfo(tag)); + if (translated is null) continue; + + var extra = translated.Keys.Except(neutral.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal).ToList(); + + 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( + "A translated resx catalog may omit keys (they fall back to English), but it must not " + + "declare a key the neutral catalog lacks, nor change a message's placeholders. " + + "Violations:\n " + string.Join("\n ", violations)); + } +} diff --git a/src/Tests/Architecture.Tests/LocalizedExceptionShapeTests.cs b/src/Tests/Architecture.Tests/LocalizedExceptionShapeTests.cs new file mode 100644 index 0000000000..270f67387f --- /dev/null +++ b/src/Tests/Architecture.Tests/LocalizedExceptionShapeTests.cs @@ -0,0 +1,49 @@ +using FSH.Framework.Core.Exceptions; +using Shouldly; +using System.Reflection; +using Xunit; + +namespace Architecture.Tests; + +/// +/// Audit.RealExceptionType reports a localization wrapper as its base type, so an audit query +/// filtering on exceptionType keeps matching the BCL exception after an endpoint is localized. +/// That single step up is only correct while the wrappers are leaves: a subclass of one would +/// report the wrapper itself, and the same BCL exception would show up under two names. +/// The rule is cheaper to enforce than the walk is to write, so it is enforced here. +/// +public sealed class LocalizedExceptionShapeTests +{ + [Fact] + public void Every_localization_wrapper_around_a_BCL_exception_is_sealed() + { + var assemblies = ModuleAssemblyDiscovery.GetModuleAssemblies() + .Append(typeof(ILocalizableMessage).Assembly) + .Distinct() + .ToArray(); + + var offenders = assemblies + .SelectMany(SafeGetTypes) + .Where(t => t.IsClass && !t.IsAbstract) + .Where(typeof(ILocalizableMessage).IsAssignableFrom) + // CustomException is ours, not a wrapper around a BCL type, and is meant to be derived from. + .Where(t => !typeof(CustomException).IsAssignableFrom(t)) + .Where(t => !t.IsSealed) + .Select(t => t.FullName!) + .ToList(); + + offenders.ShouldBeEmpty(); + } + + private static IEnumerable SafeGetTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t is not null)!; + } + } +} diff --git a/src/Tests/Architecture.Tests/LocalizedKeyReferenceTests.cs b/src/Tests/Architecture.Tests/LocalizedKeyReferenceTests.cs new file mode 100644 index 0000000000..38d74cf5e9 --- /dev/null +++ b/src/Tests/Architecture.Tests/LocalizedKeyReferenceTests.cs @@ -0,0 +1,346 @@ +using FSH.Framework.Core.Localization; +using Shouldly; +using System.Globalization; +using System.Resources; +using System.Text.RegularExpressions; +using Xunit; + +namespace Architecture.Tests; + +/// +/// Code → resx guard: every literal key the code hands to CustomException.Localized, to a +/// localization wrapper's MessageKey or to an IStringLocalizer<T> must exist in the +/// neutral catalog it targets, and that catalog entry's {n} placeholders must fit the +/// arguments passed with it. +/// +/// Neither mistake is visible at runtime: GlobalExceptionHandler answers a missing key or a +/// FormatException with the English exception message, and IStringLocalizer answers a missing key +/// with the key itself. A typo, or a key added to the wrong module's catalog, ships green otherwise. +/// +/// The scan reads source text, not syntax trees, so it only sees string-literal keys. A key passed +/// through a variable is listed in and reviewed by hand. +/// +public sealed partial class LocalizedKeyReferenceTests +{ + private const string SharedCatalog = nameof(SharedResources); + + // ponytail: RoleService.EnsureNotSystemRole receives its key as a parameter, so its callers' + // literal keys are not checked here. If more helpers like it appear, scan the callers instead. + private static readonly string[] NonLiteralKeySites = + [ + "src/BuildingBlocks/Core/Exceptions/CustomException.cs: string key", + "src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs: messageKey", + ]; + + [Fact] + public void Every_Literal_Resource_Key_Exists_In_Its_Catalog_With_Enough_Arguments() + { + var scan = new Scan(LoadNeutralCatalogs()); + foreach (var (path, source) in SourceFiles()) + { + scan.FactoryCalls(path, source); + scan.WrapperKeys(path, source); + scan.IndexerCalls(path, source); + } + + // A pattern that stops matching would leave this test asserting nothing, and green. The + // floors sit well under today's counts (≈300, 5 and ≈100) so routine deletions still pass. + scan.FactoryCallCount.ShouldBeGreaterThan(250, "the .Localized(...) scan found too few calls; the pattern probably broke"); + scan.WrapperKeyCount.ShouldBeGreaterThan(3, "the MessageKey initializer scan found too few wrappers; the pattern probably broke"); + scan.IndexerCallCount.ShouldBeGreaterThan(80, "the localizer[\"...\"] scan found too few calls; the pattern probably broke"); + + scan.NonLiteral.Distinct().Order(StringComparer.Ordinal).ToArray().ShouldBe( + NonLiteralKeySites, + "a key passed through a variable cannot be checked against its catalog; pass a literal, " + + "or add the site to NonLiteralKeySites after checking its callers"); + + scan.Violations.ShouldBeEmpty( + "Every key must exist in the neutral (English) resx of the catalog it targets, and that " + + "entry's {n} placeholders must fit the arguments passed with it. Violations:\n " + + string.Join("\n ", scan.Violations)); + } + + // The argument count drives the placeholder check, so a comma inside a literal or a comment must + // not split an argument, and an apostrophe in a comment must not open a char literal. + [Theory] + [InlineData("(\"K\", a, b)", 3)] + [InlineData("(\"K\", // the user's id, not a separator\n a)", 2)] + [InlineData("(\"K\", /* x, y */ a)", 2)] + [InlineData("(\"K\", $\"{a}, {{b}}\", 'x', ',')", 4)] + [InlineData("(\"K\", @\"say \"\"hi, there\"\"\")", 2)] + [InlineData("(\"K\", string.Join(\", \", ids))", 2)] + public void Argument_Splitter_Ignores_Commas_Inside_Literals_Brackets_And_Comments(string call, int expected) + { + ArgumentNullException.ThrowIfNull(call); + SplitArguments(call, 0).Count.ShouldBe(expected); + } + + private sealed class Scan(Dictionary> catalogs) + { + public List Violations { get; } = []; + + public List NonLiteral { get; } = []; + + public int FactoryCallCount { get; private set; } + + public int WrapperKeyCount { get; private set; } + + public int IndexerCallCount { get; private set; } + + public void FactoryCalls(string path, string source) + { + foreach (var call in Code(FactoryCallRegex(), source)) + { + var args = SplitArguments(source, call.Index + call.Length - 1); + if (!TryReadLiteral(args[0], out var key)) + { + NonLiteral.Add($"{path}: {args[0]}"); + continue; + } + + FactoryCallCount++; + // Catalogs are keyed by the marker's short name, so a qualified `Ns.XResources` keeps its last segment. + var catalog = call.Groups["catalog"].Success ? call.Groups["catalog"].Value.Split('.')[^1] : SharedCatalog; + Check(Where(path, source, call.Index), catalog, key, args.Count - 1); + } + } + + public void WrapperKeys(string path, string source) + { + foreach (var assignment in Code(WrapperKeyRegex(), source)) + { + WrapperKeyCount++; + var open = source.LastIndexOf('{', assignment.Index); + var body = source[(open + 1)..MatchingClose(source, open)]; + var resourceSource = ResourceSourceRegex().Match(body); + var catalog = resourceSource.Success ? resourceSource.Groups["catalog"].Value : SharedCatalog; + var argsAt = body.IndexOf("MessageArgs", StringComparison.Ordinal); + var argCount = argsAt < 0 ? 0 : SplitArguments(body, body.IndexOf('[', argsAt)).Count; + Check(Where(path, source, assignment.Index), catalog, assignment.Groups["key"].Value, argCount); + } + } + + public void IndexerCalls(string path, string source) + { + var localizers = LocalizerFields(source); + foreach (var index in Code(IndexerRegex(), source)) + { + var name = index.Groups["name"].Value; + var where = Where(path, source, index.Index); + if (!localizers.TryGetValue(name, out var catalog)) + { + Violations.Add($"{where} '{name}' is not declared as an IStringLocalizer in this file, so its catalog is unknown"); + continue; + } + + if (catalog is null) + { + Violations.Add($"{where} '{name}' is declared with two different catalogs in this file"); + continue; + } + + IndexerCallCount++; + var args = SplitArguments(source, index.Index + name.Length); + Check(where, catalog, index.Groups["key"].Value, args.Count - 1); + } + } + + private void Check(string where, string catalog, string key, int argCount) + { + if (!catalogs.TryGetValue(catalog, out var entries)) + { + Violations.Add($"{where} '{key}': '{catalog}' is not an embedded resx catalog"); + return; + } + + if (!entries.TryGetValue(key, out var value)) + { + Violations.Add($"{where} '{key}' is missing from {catalog}.resx"); + return; + } + + var indexes = CatalogParityTests.PlaceholderIndexes(value); + var needed = indexes.Count == 0 ? 0 : indexes.Max + 1; + if (needed > argCount) + { + Violations.Add($"{where} '{key}' in {catalog}.resx uses {needed} argument(s) but the call passes {argCount}"); + } + } + } + + private static Dictionary> LoadNeutralCatalogs() + { + var catalogs = new Dictionary>(StringComparer.Ordinal); + foreach (var marker in CatalogParityTests.DiscoverCatalogMarkers()) + { + // Call sites name the marker by its short name; two markers sharing one would make + // the lookup pick either catalog. + catalogs.ContainsKey(marker.Name).ShouldBeFalse($"two catalog markers are named {marker.Name}"); + catalogs[marker.Name] = CatalogParityTests.OwnEntries(new ResourceManager(marker), CultureInfo.InvariantCulture) ?? []; + } + + return catalogs; + } + + private static IEnumerable<(string Path, string Source)> SourceFiles() + { + var root = ModuleArchitectureTestsFixture.SolutionRoot; + return new[] { "BuildingBlocks", "Modules" } + .SelectMany(folder => Directory.EnumerateFiles(Path.Combine(root, "src", folder), "*.cs", SearchOption.AllDirectories)) + .Select(file => Path.GetRelativePath(root, file).Replace('\\', '/')) + .Where(relative => !relative.Contains("/bin/", StringComparison.Ordinal) && !relative.Contains("/obj/", StringComparison.Ordinal)) + .Select(relative => (relative, File.ReadAllText(Path.Combine(root, relative)))); + } + + /// Matches outside // and /// comment lines, where XML docs mention the API. + private static IEnumerable Code(Regex pattern, string source) => + pattern.Matches(source).Where(match => + { + var lineStart = source.LastIndexOf('\n', Math.Max(0, match.Index - 1)) + 1; + return !source.AsSpan(lineStart, match.Index - lineStart).TrimStart().StartsWith("//", StringComparison.Ordinal); + }); + + private static string Where(string path, string source, int at) => + $"{path}:{source.AsSpan(0, at).Count('\n') + 1}"; + + /// + /// Maps each IStringLocalizer<T> field or parameter in the file to T's short name; a name + /// bound to two different catalogs maps to null. + /// + private static Dictionary LocalizerFields(string source) + { + var fields = new Dictionary(StringComparer.Ordinal); + foreach (Match declaration in LocalizerDeclarationRegex().Matches(source)) + { + var name = declaration.Groups["name"].Value; + var catalog = declaration.Groups["catalog"].Value; + fields[name] = fields.TryGetValue(name, out var existing) && existing != catalog ? null : catalog; + } + + return fields; + } + + /// + /// The top-level, comma-separated arguments between the bracket at and + /// its match, with string literals and nested brackets kept whole. + /// + private static List SplitArguments(string source, int open) + { + var close = MatchingClose(source, open); + var args = new List(); + var start = open + 1; + for (var i = open + 1; i < close; i = Next(source, i)) + { + if (source[i] == ',') + { + args.Add(source[start..i].Trim()); + start = i + 1; + } + } + + var last = source[start..close].Trim(); + if (last.Length > 0 || args.Count > 0) + { + args.Add(last); + } + + return args; + } + + private static int MatchingClose(string source, int open) + { + var closer = source[open] switch { '(' => ')', '[' => ']', _ => '}' }; + for (var i = open + 1; i < source.Length; i = Next(source, i)) + { + if (source[i] == closer) + { + return i; + } + } + + throw new InvalidOperationException($"unbalanced '{source[open]}' at offset {open}"); + } + + /// + /// The index after the token at , stepping over whole literals, bracket groups and + /// comments, so a comma, quote or apostrophe inside a comment within a call is not read as code. + /// + private static int Next(string source, int i) => source[i] switch + { + '"' or '\'' => SkipLiteral(source, i) + 1, + '(' or '[' or '{' => MatchingClose(source, i) + 1, + '/' when i + 1 < source.Length && source[i + 1] is '/' or '*' => SkipComment(source, i), + _ => i + 1, + }; + + private static int SkipComment(string source, int slash) + { + if (source[slash + 1] == '/') + { + var lineEnd = source.IndexOf('\n', slash); + return lineEnd < 0 ? source.Length : lineEnd; + } + + var blockEnd = source.IndexOf("*/", slash + 2, StringComparison.Ordinal); + return blockEnd < 0 + ? throw new InvalidOperationException($"unterminated comment at offset {slash}") + : blockEnd + 2; + } + + /// Index of the quote closing the literal that opens at . + private static int SkipLiteral(string source, int quote) + { + var prefix = source[Math.Max(0, quote - 2)..quote]; + var verbatim = prefix.EndsWith('@') || prefix == "@$"; + var interpolated = prefix.EndsWith('$') || prefix == "$@"; + var i = quote + 1; + while (i < source.Length) + { + var c = source[i]; + if (c == source[quote] && !(verbatim && i + 1 < source.Length && source[i + 1] == c)) + { + return i; + } + + i += c switch + { + '\\' when !verbatim => 2, + '{' when interpolated && source[i + 1] != '{' => MatchingClose(source, i) - i + 1, + _ when c == source[quote] || (c is '{' && interpolated) => 2, + _ => 1, + }; + } + + throw new InvalidOperationException($"unterminated literal at offset {quote}"); + } + + private static bool TryReadLiteral(string argument, out string value) + { + var match = PlainLiteralRegex().Match(argument); + value = match.Success ? match.Groups["value"].Value : string.Empty; + return match.Success; + } + + // `.Localized(`, `.Localized (` and `.Localized(`. Requiring + // `<` or `(` right after the name keeps LocalizedKeyNotFoundException and its siblings out. + [GeneratedRegex(@"(?[\w.]+)\s*>)?\s*\(")] + private static partial Regex FactoryCallRegex(); + + // The localization wrappers around BCL exceptions keep the initializer form. + [GeneratedRegex(@"MessageKey\s*=\s*""(?[^""]+)""")] + private static partial Regex WrapperKeyRegex(); + + [GeneratedRegex(@"ResourceSource\s*=\s*typeof\((?\w+)\)")] + private static partial Regex ResourceSourceRegex(); + + // A field or parameter (`IStringLocalizer localizer`) or a local resolved from DI + // (`var localizer = services.GetRequiredService>()`). + [GeneratedRegex(@"IStringLocalizer<(?\w+)>\??\s+(?\w+)|var\s+(?\w+)\s*=[^;]*?IStringLocalizer<(?\w+)>")] + private static partial Regex LocalizerDeclarationRegex(); + + [GeneratedRegex(@"(?\w*[Ll]ocalizer)\[\s*""(?[^""]+)""")] + private static partial Regex IndexerRegex(); + + [GeneratedRegex(@"^""(?[^""\\]*)""$")] + private static partial Regex PlainLiteralRegex(); +} diff --git a/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs b/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs new file mode 100644 index 0000000000..ea936eae61 --- /dev/null +++ b/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs @@ -0,0 +1,82 @@ +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 +// pt-BR key the neutral catalog lacks is an orphan of a rename or delete. Both are caught here. +// A missing pt-BR entry is allowed: it falls back to English. +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 Every_ptBR_key_exists_in_the_neutral_catalog() + { + var neutral = KeysFor(string.Empty); // AuditingResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // AuditingResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.Except(neutral, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ShouldBeEmpty(); + } + + [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..504cccf55e --- /dev/null +++ b/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Globalization; +using FSH.Modules.Billing.Contracts; +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 +// pt-BR key the neutral catalog lacks is an orphan of a rename or delete. Both are caught here. +// A missing pt-BR entry is allowed: it falls back to English. +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 Every_ptBR_key_exists_in_the_neutral_catalog() + { + var neutral = KeysFor(string.Empty); // BillingResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // BillingResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.Except(neutral, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ShouldBeEmpty(); + } + + [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; + } + } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which is a code + // identifier, not display text, so every member needs a neutral entry; pt-BR may omit it and fall back. + [Theory] + [InlineData(typeof(TopupRequestStatus))] + public void Every_enum_member_that_reaches_a_message_has_a_neutral_entry(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + } + } +} diff --git a/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs b/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs new file mode 100644 index 0000000000..e72c16515d --- /dev/null +++ b/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs @@ -0,0 +1,106 @@ +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 +// pt-BR key the neutral catalog lacks is an orphan of a rename or delete. Both are caught here. +// A missing pt-BR entry is allowed: it falls back to English. +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 Every_ptBR_key_exists_in_the_neutral_catalog() + { + var neutral = KeysFor(string.Empty); // CatalogResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // CatalogResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.Except(neutral, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ShouldBeEmpty(); + } + + [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/Support/TestUiCulture.cs b/src/Tests/Catalog.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..737f745999 --- /dev/null +++ b/src/Tests/Catalog.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Catalog.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} 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..20e4b99633 --- /dev/null +++ b/src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs @@ -0,0 +1,82 @@ +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 +// pt-BR key the neutral catalog lacks is an orphan of a rename or delete. Both are caught here. +// A missing pt-BR entry is allowed: it falls back to English. +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 Every_ptBR_key_exists_in_the_neutral_catalog() + { + var neutral = KeysFor(string.Empty); // ChatResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // ChatResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.Except(neutral, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ShouldBeEmpty(); + } + + [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..c0a2db023a --- /dev/null +++ b/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Globalization; +using FSH.Modules.Files.Contracts.v1.DTOs; +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 +// pt-BR key the neutral catalog lacks is an orphan of a rename or delete. Both are caught here. +// A missing pt-BR entry is allowed: it falls back to English. +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 Every_ptBR_key_exists_in_the_neutral_catalog() + { + var neutral = KeysFor(string.Empty); // FilesResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // FilesResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.Except(neutral, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ShouldBeEmpty(); + } + + [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; + } + } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which is a code + // identifier, not display text, so every member needs a neutral entry; pt-BR may omit it and fall back. + [Theory] + [InlineData(typeof(FileAssetStatus))] + [InlineData(typeof(Visibility))] + public void Every_enum_member_that_reaches_a_message_has_a_neutral_entry(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + } + } +} diff --git a/src/Tests/Framework.Tests/Core/ExceptionsTests.cs b/src/Tests/Framework.Tests/Core/ExceptionsTests.cs index 2e251f189e..45cfa8d531 100644 --- a/src/Tests/Framework.Tests/Core/ExceptionsTests.cs +++ b/src/Tests/Framework.Tests/Core/ExceptionsTests.cs @@ -142,4 +142,66 @@ public void UnauthorizedException_Should_Map401_When_Constructed() } #endregion + + #region Localized factory + + [Fact] + public void Localized_Should_SetKeyArgsAndCatalog_When_ModuleCatalogNamed() + { + // Arrange + var exception = new NotFoundException("Item 42 not found."); + + // Act + var localized = exception.Localized("Module.ItemNotFound", 42, "x"); + + // Assert: the same instance comes back, so `throw` keeps the subclass and its status code. + localized.ShouldBeSameAs(exception); + localized.StatusCode.ShouldBe(HttpStatusCode.NotFound); + localized.MessageKey.ShouldBe("Module.ItemNotFound"); + localized.MessageArgs.ShouldBe(new object[] { 42, "x" }); + localized.ResourceSource.ShouldBe(typeof(ExceptionsTests)); + localized.Message.ShouldBe("Item 42 not found."); + } + + [Fact] + public void Localized_Should_UseSharedCatalog_When_NoCatalogNamed() + { + // Arrange & Act + var exception = new NotFoundException("gone").Localized("Error.NotFound"); + + // Assert + exception.MessageKey.ShouldBe("Error.NotFound"); + exception.MessageArgs.ShouldBeEmpty(); + exception.ResourceSource.ShouldBeNull(); + } + + [Fact] + public void DefaultCtors_Should_CarrySharedKeys_When_NoMessageGiven() + { + // Arrange & Act + var forbidden = new ForbiddenException(); + var unauthorized = new UnauthorizedException(); + + // Assert + forbidden.MessageKey.ShouldBe("Error.ForbiddenAccess"); + forbidden.ResourceSource.ShouldBeNull(); + unauthorized.MessageKey.ShouldBe("Error.AuthenticationFailed"); + unauthorized.ResourceSource.ShouldBeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Localized_Should_Throw_When_KeyIsBlank(string key) + { + // Arrange + var exception = new NotFoundException("gone"); + + // Act & Assert + Should.Throw(() => exception.Localized(key)); + Should.Throw(() => exception.Localized(key)); + exception.MessageKey.ShouldBeNull(); + } + + #endregion } diff --git a/src/Tests/Framework.Tests/Localization/FallbackProbeResources.pt-BR.resx b/src/Tests/Framework.Tests/Localization/FallbackProbeResources.pt-BR.resx index e8714489ae..4633fcdf8c 100644 --- a/src/Tests/Framework.Tests/Localization/FallbackProbeResources.pt-BR.resx +++ b/src/Tests/Framework.Tests/Localization/FallbackProbeResources.pt-BR.resx @@ -15,4 +15,7 @@ Traduzido em português + + Item {0} não foi encontrado + diff --git a/src/Tests/Framework.Tests/Localization/FallbackProbeResources.resx b/src/Tests/Framework.Tests/Localization/FallbackProbeResources.resx index e0ee3e06bc..9bcdcbe53b 100644 --- a/src/Tests/Framework.Tests/Localization/FallbackProbeResources.resx +++ b/src/Tests/Framework.Tests/Localization/FallbackProbeResources.resx @@ -18,4 +18,7 @@ Only in English + + Item {0} was not found + diff --git a/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs b/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs index 6efe140041..f7a86a6a57 100644 --- a/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs +++ b/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs @@ -5,6 +5,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Web.Exceptions; using FSH.Framework.Web.Localization; +using Framework.Tests.Localization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; @@ -97,7 +98,7 @@ private static async Task RunAsync(Action configur [InlineData("en-US")] public async Task Request_culture_is_negotiated_from_the_accept_language_header(string acceptLanguage) { - var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + var exception = new NotFoundException("English fallback").Localized("Error.NotFound"); var outcome = await InvokeAsync(acceptLanguage, exception); @@ -110,7 +111,7 @@ public async Task Request_culture_is_negotiated_from_the_accept_language_header( [InlineData("en-US", "Not Found")] public async Task Detail_is_localized_from_the_negotiated_request_culture(string acceptLanguage, string expected) { - var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + var exception = new NotFoundException("English fallback").Localized("Error.NotFound"); var outcome = await InvokeAsync(acceptLanguage, exception); @@ -118,6 +119,22 @@ public async Task Detail_is_localized_from_the_negotiated_request_culture(string outcome.Detail.ShouldBe(expected); } + // Localized must carry the module catalog and the arguments to the handler: without the + // catalog the key is looked up in the shared one and the English fallback comes back; without + // the arguments the placeholder reaches the client unformatted. + [Theory] + [InlineData("pt-BR", "Item 42 não foi encontrado")] + [InlineData("en-US", "Item 42 was not found")] + public async Task Detail_is_localized_from_the_module_catalog_named_by_the_factory(string acceptLanguage, string expected) + { + var exception = new NotFoundException("English fallback").Localized("Probe.ItemNotFound", 42); + + var outcome = await InvokeAsync(acceptLanguage, exception); + + outcome.Code.ShouldBe("Probe.ItemNotFound"); + outcome.Detail.ShouldBe(expected); + } + // Title comes from the status-mapped catalog key through the injected IStringLocalizer, a separate // resolution path from Detail IStringLocalizerFactory. Both read the ambient culture, so both broke. [Theory] @@ -149,7 +166,7 @@ public async Task Unexpected_error_is_localized_from_the_negotiated_request_cult [InlineData("en-US")] public async Task Content_language_declares_the_culture_the_body_was_written_in(string acceptLanguage) { - var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + var exception = new NotFoundException("English fallback").Localized("Error.NotFound"); var outcome = await InvokeAsync(acceptLanguage, exception); @@ -161,7 +178,7 @@ public async Task Content_language_declares_the_culture_the_body_was_written_in( [Fact] public async Task Handler_still_answers_when_no_culture_was_negotiated() { - var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + var exception = new NotFoundException("English fallback").Localized("Error.NotFound"); var outcome = await InvokeWithoutLocalizationAsync("pt-BR", exception); @@ -177,7 +194,7 @@ public async Task Handler_still_answers_when_no_culture_was_negotiated() [Fact] public async Task Ambient_culture_is_restored_after_handling() { - var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + var exception = new NotFoundException("English fallback").Localized("Error.NotFound"); var before = CultureInfo.CurrentUICulture; await InvokeAsync("pt-BR", exception); diff --git a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs index 48ab08a8b6..b7da94dd4f 100644 --- a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs +++ b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs @@ -89,7 +89,7 @@ public async Task CustomException_title_is_localized_by_status(string culture, s [InlineData("en-US", "Unauthorized")] public async Task CustomException_detail_is_localized_from_key(string culture, string expected) { - var exception = new UnauthorizedException("english fallback") { MessageKey = "Error.Unauthorized" }; + var exception = new UnauthorizedException("english fallback").Localized("Error.Unauthorized"); var (_, detail) = await HandleAsync(exception, culture); detail.ShouldBe(expected); } @@ -119,7 +119,7 @@ public async Task Parameterless_unauthorized_detail_is_localized(string culture, [Fact] public async Task CustomException_detail_falls_back_when_key_missing() { - var exception = new NotFoundException("English fallback detail.") { MessageKey = "Does.Not.Exist" }; + var exception = new NotFoundException("English fallback detail.").Localized("Does.Not.Exist"); var (_, detail) = await HandleAsync(exception, "pt-BR"); detail.ShouldBe("English fallback detail."); } @@ -167,7 +167,7 @@ public async Task LocalizedUnauthorizedAccess_without_key_falls_back_to_message( [InlineData("en-US")] public async Task CustomException_surfaces_the_message_key_as_code(string culture) { - var exception = new UnauthorizedException("english fallback") { MessageKey = "Error.Unauthorized" }; + var exception = new UnauthorizedException("english fallback").Localized("Error.Unauthorized"); var (_, _, code) = await HandleWithCodeAsync(exception, culture); code.ShouldBe("Error.Unauthorized"); } @@ -194,7 +194,7 @@ public async Task Exception_without_key_omits_the_code() [Fact] public async Task Unknown_key_still_surfaces_as_code() { - var exception = new NotFoundException("English fallback detail.") { MessageKey = "Does.Not.Exist" }; + var exception = new NotFoundException("English fallback detail.").Localized("Does.Not.Exist"); var (_, detail, code) = await HandleWithCodeAsync(exception, "pt-BR"); detail.ShouldBe("English fallback detail."); code.ShouldBe("Does.Not.Exist"); @@ -252,4 +252,35 @@ public async Task Untranslated_status_falls_back_to_the_exception_type_name(stri title.ShouldBe(nameof(CustomException)); } + + // The lookup is by "{EnumType}.{Member}" against the message own catalog. SharedResources ships + // no enum of its own, so these two stand in for the two outcomes: a member whose name happens to + // match an existing key (translated) and one that matches nothing (kept as the C# name). + private enum Error { Unauthorized } + + private enum Untranslated { Member } + + // Without this, a pt-BR reader gets a translated sentence ending in an English enum name: + // "um chamado no status Closed". + [Fact] + public async Task An_enum_argument_is_translated_through_the_same_catalog() + { + var exception = new CustomException("english fallback", [], HttpStatusCode.BadRequest) + .Localized("Validation.ImpersonationTakeRange", Error.Unauthorized); + + var (_, detail) = await HandleAsync(exception, "pt-BR"); + + detail.ShouldBe("O valor de Take deve estar entre 1 e Não autorizado."); + } + + [Fact] + public async Task An_enum_argument_with_no_entry_keeps_its_member_name() + { + var exception = new CustomException("english fallback", [], HttpStatusCode.BadRequest) + .Localized("Validation.ImpersonationTakeRange", Untranslated.Member); + + var (_, detail) = await HandleAsync(exception, "pt-BR"); + + detail.ShouldBe("O valor de Take deve estar entre 1 e Member."); + } } 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/Support/TestUiCulture.cs b/src/Tests/Generic.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..824b0ec9a7 --- /dev/null +++ b/src/Tests/Generic.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Generic.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} 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 d6928f4069..70ef3cf0bb 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 PageNumber_Should_Pass_When_AtUpperBound_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = 1_000_000 }; // Act @@ -136,7 +143,7 @@ public void PageNumber_Should_Pass_When_AtUpperBound_Auditing() public void PageNumber_Should_Fail_When_AboveUpperBound_Auditing(int pageNumber) { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = pageNumber }; // Act @@ -150,7 +157,7 @@ public void PageNumber_Should_Fail_When_AboveUpperBound_Auditing(int pageNumber) public void PageNumber_Should_Fail_When_AboveUpperBound_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageNumber = int.MaxValue }; // Act @@ -164,7 +171,7 @@ public void PageNumber_Should_Fail_When_AboveUpperBound_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 @@ -178,7 +185,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 @@ -195,7 +202,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 @@ -212,7 +219,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 @@ -226,7 +233,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 @@ -240,7 +247,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 @@ -254,7 +261,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 @@ -268,7 +275,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..d3e10a71f7 --- /dev/null +++ b/src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs @@ -0,0 +1,82 @@ +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 +// pt-BR key the neutral catalog lacks is an orphan of a rename or delete. Both are caught here. +// A missing pt-BR entry is allowed: it falls back to English. +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 Every_ptBR_key_exists_in_the_neutral_catalog() + { + var neutral = KeysFor(string.Empty); // IdentityResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // IdentityResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.Except(neutral, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ShouldBeEmpty(); + } + + [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/Services/ConfirmEmailMessageTests.cs b/src/Tests/Identity.Tests/Services/ConfirmEmailMessageTests.cs new file mode 100644 index 0000000000..b1c1ce6e8d --- /dev/null +++ b/src/Tests/Identity.Tests/Services/ConfirmEmailMessageTests.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using System.Text; +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Eventing.Outbox; +using FSH.Framework.Jobs.Services; +using FSH.Framework.Mailing.Services; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; +using FSH.Modules.Identity.Services; +using Identity.Tests.Support; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using NSubstitute; + +namespace Identity.Tests.Services; + +/// +/// The confirm-email success text is rendered verbatim by both SPAs' confirmation page, so it has to +/// follow the caller's language like every other server message. +/// +public sealed class ConfirmEmailMessageTests +{ + [Theory] + [InlineData("en-US", "Your email u@codefi.com.br is confirmed. You can now sign in.")] + [InlineData("pt-BR", "Seu e-mail u@codefi.com.br foi confirmado. Agora você já pode entrar.")] + public async Task ConfirmEmailAsync_returns_the_success_message_in_the_request_language(string culture, string expected) + { + // Arrange + var user = new FshUser { Id = "u1", Email = "u@codefi.com.br", UserName = "u", EmailConfirmed = false }; + var userManager = Substitute.For>( + Substitute.For>(), null, null, null, null, null, null, null, null); + userManager.Users.Returns(new[] { user }.AsAsyncQueryable()); + userManager.ConfirmEmailAsync(user, "token").Returns(IdentityResult.Success); + + var tenant = Substitute.For>(); + tenant.MultiTenantContext.Returns(new MultiTenantContext(new AppTenantInfo("root", "root"))); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + var localizer = services.BuildServiceProvider().GetRequiredService>(); + + var sut = new UserRegistrationService( + userManager, + db: null!, + Substitute.For(), + Substitute.For(), + tenant, + Substitute.For(), + localizer); + var code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("token")); + + var previous = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo(culture); + try + { + // Act + var message = await sut.ConfirmEmailAsync("u1", code, "root", CancellationToken.None); + + // Assert + message.ShouldBe(expected); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Identity.Tests/Support/TestUiCulture.cs b/src/Tests/Identity.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..54b69f1b47 --- /dev/null +++ b/src/Tests/Identity.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Identity.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} 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..ffa0d19c5d --- /dev/null +++ b/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Globalization; +using FSH.Modules.Multitenancy.Provisioning; +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 +// pt-BR key the neutral catalog lacks is an orphan of a rename or delete. Both are caught here. +// A missing pt-BR entry is allowed: it falls back to English. +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 Every_ptBR_key_exists_in_the_neutral_catalog() + { + var neutral = KeysFor(string.Empty); // MultitenancyResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // MultitenancyResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.Except(neutral, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ShouldBeEmpty(); + } + + [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; + } + } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which is a code + // identifier, not display text, so every member needs a neutral entry; pt-BR may omit it and fall back. + [Theory] + [InlineData(typeof(TenantProvisioningStatus))] + public void Every_enum_member_that_reaches_a_message_has_a_neutral_entry(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + } + } +} 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..5d2e5d4207 --- /dev/null +++ b/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs @@ -0,0 +1,128 @@ +using System; +using System.Globalization; +using FSH.Modules.Tickets.Contracts.Dtos; +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 pt-BR key the neutral catalog lacks is an orphan of a rename or delete. Both are +// caught here; a missing pt-BR entry is allowed and falls back to English. 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 Every_ptBR_key_exists_in_the_neutral_catalog() + { + var neutral = KeysFor(string.Empty); // TicketsResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // TicketsResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.Except(neutral, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ShouldBeEmpty(); + } + + [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."); + // The status argument arrives already localized: GlobalExceptionHandler resolves an enum + // argument through this same catalog before formatting, so asserting the raw "Open" here + // would pin a sentence no user ever sees. + localizer["Tickets.OnlyResolvedCanClose", localizer["TicketStatus.Open"].Value].Value + .ShouldBe("Somente um chamado resolvido pode ser fechado. O status atual é Aberto. Resolva-o primeiro."); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which is a code + // identifier, not display text, so every member needs a neutral entry; pt-BR may omit it and fall back. + [Theory] + [InlineData(typeof(TicketStatus))] + public void Every_enum_member_that_reaches_a_message_has_a_neutral_entry(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + } + } +} 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..85099743db --- /dev/null +++ b/src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs @@ -0,0 +1,82 @@ +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 +// pt-BR key the neutral catalog lacks is an orphan of a rename or delete. Both are caught here. +// A missing pt-BR entry is allowed: it falls back to English. +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 Every_ptBR_key_exists_in_the_neutral_catalog() + { + var neutral = KeysFor(string.Empty); // WebhooksResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // WebhooksResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.Except(neutral, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal) + .ShouldBeEmpty(); + } + + [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/TestUiCulture.cs b/src/Tests/Webhooks.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..685290eefb --- /dev/null +++ b/src/Tests/Webhooks.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Webhooks.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} 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);