From 0a1f6a39ee52ba138cc354e0514e68e73aab62ce Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Fri, 13 Mar 2026 16:53:57 -0300 Subject: [PATCH 01/24] feat: implement Providers and Search Providers modules, including entities, DTOs, mappers, repositories, event handlers, APIs, and comprehensive tests. --- prompts/design-react-project.md | 262 ++++++++++++++++++ .../Providers/DTOs/ModuleProviderBasicDto.cs | 1 + .../Providers/DTOs/ModuleProviderDto.cs | 1 + .../DTOs/ModuleProviderIndexingDto.cs | 1 + .../Providers/Application/DTOs/ProviderDto.cs | 1 + .../Application/DTOs/PublicProviderDto.cs | 1 + .../GetPublicProviderByIdQueryHandler.cs | 1 + .../Application/Mappers/ProviderMapper.cs | 9 +- .../ModuleApi/ProvidersModuleApi.cs | 3 + .../Providers/Domain/Entities/Provider.cs | 20 +- .../ProviderProfileUpdatedDomainEvent.cs | 2 + .../Events/ProviderRegisteredDomainEvent.cs | 4 +- .../Repositories/IProviderRepository.cs | 8 + .../Configurations/ProviderConfiguration.cs | 9 + .../Repositories/ProviderRepository.cs | 9 + .../GetMyProviderProfileEndpointTests.cs | 19 +- .../GetMyProviderStatusEndpointTests.cs | 19 +- .../UpdateMyProviderProfileEndpointTests.cs | 19 +- .../UploadMyDocumentEndpointTests.cs | 38 ++- .../Services/ProvidersModuleApiTests.cs | 1 + .../Unit/Domain/Entities/ProviderTests.cs | 3 + ...erProfileUpdatedDomainEventHandlerTests.cs | 2 + ...oviderRegisteredDomainEventHandlerTests.cs | 9 +- .../Application/DTOs/SearchableProviderDto.cs | 1 + .../Handlers/SearchProvidersQueryHandler.cs | 1 + .../ModuleApi/SearchProvidersModuleApi.cs | 3 +- .../Domain/Entities/SearchableProvider.cs | 22 +- .../SearchableProviderConfiguration.cs | 5 + .../DTOs/ProviderSearchResultDto.cs | 1 + .../SearchableProviderRepository.cs | 2 + .../SearchProvidersIntegrationTestBase.cs | 30 +- .../SearchProvidersQueryHandlerTests.cs | 25 +- .../SearchProvidersModuleApiTests.cs | 57 ++-- .../Entities/SearchableProviderTests.cs | 12 +- .../Unit/Domain/Models/SearchResultTests.cs | 16 +- .../SearchableProviderRepositoryTests.cs | 34 ++- src/Shared/Utilities/SlugHelper.cs | 66 +++++ .../MeAjudaAi.Web.Admin/packages.lock.json | 6 + .../Unit/Utilities/SlugHelperTests.cs | 28 ++ .../Pages/ProvidersPageTests.cs | 1 + .../packages.lock.json | 6 +- 41 files changed, 657 insertions(+), 101 deletions(-) create mode 100644 src/Shared/Utilities/SlugHelper.cs create mode 100644 tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs diff --git a/prompts/design-react-project.md b/prompts/design-react-project.md index e69de29bb..232907102 100644 --- a/prompts/design-react-project.md +++ b/prompts/design-react-project.md @@ -0,0 +1,262 @@ +# Conversão de Design para Componentes React + +Analise o design anexado (screenshot ou frame do Figma) e converta para componentes React seguindo os padrões abaixo. + +--- + +## Stack + +- **React 19** (sem `forwardRef`) +- **TypeScript** strict +- **Tailwind CSS v4** com `@theme` e CSS variables +- **Base UI React** (`@base-ui/react`) para componentes headless +- **Tailwind Variants** (`tailwind-variants`) para variantes +- **Tailwind Merge** (`tailwind-merge`) para merge de classes +- **Lucide React** ou **Phosphor Icons** para ícones + +--- + +## Nomenclatura + +- Arquivos: **lowercase com hífens** → `user-card.tsx`, `use-modal.ts` +- **Sempre named exports**, nunca default export +- Não criar barrel files (`index.ts`) para pastas internas + +--- + +## Estrutura de Componente + +```tsx +import { tv, type VariantProps } from "tailwind-variants"; +import { twMerge } from "tailwind-merge"; +import type { ComponentProps } from "react"; + +export const buttonVariants = tv({ + base: [ + "inline-flex cursor-pointer items-center justify-center font-medium rounded-lg border transition-colors", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + ], + variants: { + variant: { + primary: + "border-primary bg-primary text-primary-foreground hover:bg-primary-hover", + secondary: + "border-border bg-secondary text-secondary-foreground hover:bg-muted", + ghost: + "border-transparent bg-transparent text-muted-foreground hover:text-foreground", + destructive: + "border-destructive bg-destructive text-destructive-foreground hover:bg-destructive/90", + }, + size: { + sm: "h-6 px-2 gap-1.5 text-xs [&_svg]:size-3", + md: "h-7 px-3 gap-2 text-sm [&_svg]:size-3.5", + lg: "h-9 px-4 gap-2.5 text-base [&_svg]:size-4", + }, + }, + defaultVariants: { variant: "primary", size: "md" }, +}); + +export interface ButtonProps + extends ComponentProps<"button">, VariantProps {} + +export function Button({ + className, + variant, + size, + disabled, + children, + ...props +}: ButtonProps) { + return ( + + ); +} +``` + +--- + +## Compound Components + +```tsx +import { twMerge } from "tailwind-merge"; +import type { ComponentProps } from "react"; + +export interface CardProps extends ComponentProps<"div"> {} + +export function Card({ className, ...props }: CardProps) { + return ( +
+ ); +} + +export function CardHeader({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +export function CardTitle({ className, ...props }: ComponentProps<"h3">) { + return ( +

+ ); +} + +export function CardContent({ className, ...props }: ComponentProps<"div">) { + return
; +} +``` + +--- + +## Cores (CSS Variables) + +``` +bg-surface, bg-surface-raised → fundos +bg-primary, bg-secondary, bg-muted → ações/estados +bg-destructive → erros/danger + +text-foreground → texto principal +text-foreground-subtle → texto secundário +text-muted-foreground → texto desabilitado +text-primary-foreground → texto em bg primary + +border-border, border-input → bordas padrão +border-primary, border-destructive → bordas de destaque + +ring-ring → focus ring +``` + +--- + +## TypeScript + +```tsx +// ✅ Estender ComponentProps + VariantProps +export interface ButtonProps + extends ComponentProps<"button">, VariantProps {} + +// ✅ Import type para tipos +import type { ComponentProps } from "react"; +import type { VariantProps } from "tailwind-variants"; + +// ❌ Não usar React.FC nem any +``` + +--- + +## Padrões Importantes + +```tsx +// Sempre usar twMerge +className={twMerge('classes-base', className)} + +// Sempre usar data-slot +
+ +// Estados com data-attributes +data-disabled={disabled ? '' : undefined} +className="data-[disabled]:opacity-50 data-[selected]:bg-primary" + +// Focus visible +'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' + +// Ícones com tamanho + +'[&_svg]:size-3.5' // em variantes + +// Botões de ícone precisam de aria-label + + +// Props spread no final +{...props} +``` + +--- + +## Base UI (componentes headless) + +```tsx +// Dialog +import * as Dialog from "@base-ui/react/dialog"; + + + + + +; + +// Tabs +import * as Tabs from "@base-ui/react/tabs"; + + + + + +; + +// Select +import * as Select from "@base-ui/react/select"; + + + + + + + +; + +// Menu +import * as Menu from "@base-ui/react/menu"; + + + + + + + +; +``` + +--- + +## Checklist + +- [ ] Arquivo lowercase com hífens +- [ ] Named export +- [ ] `ComponentProps<'elemento'>` + `VariantProps` +- [ ] Variantes com `tv()`, classes com `twMerge()` +- [ ] `data-slot` para identificação +- [ ] Estados via `data-[state]:` +- [ ] Cores do tema (não hardcoded) +- [ ] Focus visible em interativos +- [ ] `aria-label` em botões de ícone +- [ ] `{...props}` no final + +--- + +Agora analise o design anexado e gere o código do componente. diff --git a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderBasicDto.cs b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderBasicDto.cs index 2ca58bfe4..94055259f 100644 --- a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderBasicDto.cs +++ b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderBasicDto.cs @@ -6,6 +6,7 @@ namespace MeAjudaAi.Contracts.Modules.Providers.DTOs; public sealed record ModuleProviderBasicDto( Guid Id, string Name, + string Slug, string Email, string ProviderType, string VerificationStatus, diff --git a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderDto.cs b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderDto.cs index 29e36eb37..0ef4636ed 100644 --- a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderDto.cs +++ b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderDto.cs @@ -8,6 +8,7 @@ namespace MeAjudaAi.Contracts.Modules.Providers.DTOs; public sealed record ModuleProviderDto( Guid Id, string Name, + string Slug, string Email, string Document, [property: JsonPropertyName("type")] diff --git a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderIndexingDto.cs b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderIndexingDto.cs index 4b257ed9d..38b5b1106 100644 --- a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderIndexingDto.cs +++ b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderIndexingDto.cs @@ -9,6 +9,7 @@ namespace MeAjudaAi.Contracts.Modules.Providers.DTOs; public sealed record ModuleProviderIndexingDto( Guid ProviderId, string Name, + string Slug, double Latitude, double Longitude, IReadOnlyCollection ServiceIds, diff --git a/src/Modules/Providers/Application/DTOs/ProviderDto.cs b/src/Modules/Providers/Application/DTOs/ProviderDto.cs index 1699b56af..340d61322 100644 --- a/src/Modules/Providers/Application/DTOs/ProviderDto.cs +++ b/src/Modules/Providers/Application/DTOs/ProviderDto.cs @@ -9,6 +9,7 @@ public sealed record ProviderDto( Guid Id, Guid UserId, string Name, + string Slug, EProviderType Type, BusinessProfileDto BusinessProfile, EProviderStatus Status, diff --git a/src/Modules/Providers/Application/DTOs/PublicProviderDto.cs b/src/Modules/Providers/Application/DTOs/PublicProviderDto.cs index 558f99d24..b3a3390de 100644 --- a/src/Modules/Providers/Application/DTOs/PublicProviderDto.cs +++ b/src/Modules/Providers/Application/DTOs/PublicProviderDto.cs @@ -9,6 +9,7 @@ namespace MeAjudaAi.Modules.Providers.Application.DTOs; public sealed record PublicProviderDto( Guid Id, string Name, + string Slug, EProviderType Type, string? FantasyName, string? Description, diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs index 043809b11..9b7414f37 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs @@ -68,6 +68,7 @@ public GetPublicProviderByIdQueryHandler(IProviderRepository providerRepository, var dto = new PublicProviderDto( provider.Id, provider.Name, + provider.Slug, provider.Type, businessProfile.FantasyName, businessProfile.Description, diff --git a/src/Modules/Providers/Application/Mappers/ProviderMapper.cs b/src/Modules/Providers/Application/Mappers/ProviderMapper.cs index 797e312ac..380ce0f9f 100644 --- a/src/Modules/Providers/Application/Mappers/ProviderMapper.cs +++ b/src/Modules/Providers/Application/Mappers/ProviderMapper.cs @@ -15,10 +15,11 @@ public static class ProviderMapper public static ProviderDto ToDto(this Provider provider) { return new ProviderDto( - provider.Id.Value, - provider.UserId, - provider.Name, - provider.Type, + Id: provider.Id.Value, + UserId: provider.UserId, + Name: provider.Name, + Slug: provider.Slug, + Type: provider.Type, provider.BusinessProfile.ToDto(), provider.Status, provider.VerificationStatus, diff --git a/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs b/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs index 6ddf188fe..d7a513fb1 100644 --- a/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs +++ b/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs @@ -334,6 +334,7 @@ public async Task>> GetProvidersByV var indexingDto = new ModuleProviderIndexingDto( ProviderId: providerEntity.Id.Value, Name: providerEntity.Name, + Slug: providerEntity.Slug, Latitude: coordinates.Latitude, Longitude: coordinates.Longitude, ServiceIds: serviceIds, @@ -360,6 +361,7 @@ private static ModuleProviderDto MapToModuleDto(ProviderDto providerDto) return new ModuleProviderDto( Id: providerDto.Id, Name: providerDto.Name, + Slug: providerDto.Slug, Email: providerDto.BusinessProfile?.ContactInfo?.Email ?? string.Empty, Document: GetMainDocument(providerDto)?.Number ?? string.Empty, ProviderType: providerDto.Type.ToString(), @@ -378,6 +380,7 @@ private static ModuleProviderBasicDto MapToModuleBasicDto(ProviderDto providerDt return new ModuleProviderBasicDto( Id: providerDto.Id, Name: providerDto.Name, + Slug: providerDto.Slug, Email: providerDto.BusinessProfile?.ContactInfo?.Email ?? string.Empty, ProviderType: providerDto.Type.ToString(), VerificationStatus: providerDto.VerificationStatus.ToString(), diff --git a/src/Modules/Providers/Domain/Entities/Provider.cs b/src/Modules/Providers/Domain/Entities/Provider.cs index d40b3bf6a..76a1cbe40 100644 --- a/src/Modules/Providers/Domain/Entities/Provider.cs +++ b/src/Modules/Providers/Domain/Entities/Provider.cs @@ -34,6 +34,14 @@ public sealed class Provider : AggregateRoot /// public string Name { get; private set; } = string.Empty; + /// + /// Slug amigável para URL. + /// + /// + /// Gerado automaticamente a partir do nome. Usado para SEO e URLs públicas. + /// + public string Slug { get; private set; } = string.Empty; + /// /// Tipo do prestador de serviços (Individual ou Company). /// @@ -129,6 +137,7 @@ public Provider( UserId = userId; Name = name.Trim(); + Slug = SlugHelper.Generate(Name); Type = type; BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; @@ -163,6 +172,7 @@ public Provider( UserId = userId; Name = name.Trim(); + Slug = SlugHelper.Generate(Name); Type = type; BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; @@ -175,7 +185,8 @@ public Provider( UserId, Name, Type, - BusinessProfile.ContactInfo.Email)); + BusinessProfile.ContactInfo.Email, + Slug)); } /// @@ -199,7 +210,12 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? var newName = name.Trim(); if (Name != newName) + { updatedFields.Add("Name"); + Name = newName; + Slug = SlugHelper.Generate(Name); + updatedFields.Add("Slug"); + } if (!BusinessProfile.ContactInfo.Email.Equals(businessProfile.ContactInfo.Email, StringComparison.OrdinalIgnoreCase)) updatedFields.Add("Email"); @@ -213,7 +229,6 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? if (BusinessProfile.Description != businessProfile.Description) updatedFields.Add("Description"); - Name = newName; BusinessProfile = businessProfile; MarkAsUpdated(); @@ -222,6 +237,7 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? 1, Name, BusinessProfile.ContactInfo.Email, + Slug, updatedBy, updatedFields.ToArray())); } diff --git a/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs b/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs index 52c636930..9eeb5e587 100644 --- a/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs +++ b/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs @@ -9,6 +9,7 @@ namespace MeAjudaAi.Modules.Providers.Domain.Events; /// Versão do agregado no momento do evento /// Novo nome do prestador de serviços /// Novo email de contato +/// Novo slug amigável para URL /// Quem fez a atualização /// Lista dos campos que foram atualizados public record ProviderProfileUpdatedDomainEvent( @@ -16,6 +17,7 @@ public record ProviderProfileUpdatedDomainEvent( int Version, string Name, string Email, + string Slug, string? UpdatedBy, string[] UpdatedFields ) : DomainEvent(AggregateId, Version); diff --git a/src/Modules/Providers/Domain/Events/ProviderRegisteredDomainEvent.cs b/src/Modules/Providers/Domain/Events/ProviderRegisteredDomainEvent.cs index e81ac8e68..04abed8ca 100644 --- a/src/Modules/Providers/Domain/Events/ProviderRegisteredDomainEvent.cs +++ b/src/Modules/Providers/Domain/Events/ProviderRegisteredDomainEvent.cs @@ -17,11 +17,13 @@ namespace MeAjudaAi.Modules.Providers.Domain.Events; /// Nome do prestador de serviços /// Tipo do prestador de serviços /// Email de contato do prestador de serviços +/// Slug amigável para URL public record ProviderRegisteredDomainEvent( Guid AggregateId, int Version, Guid UserId, string Name, EProviderType Type, - string Email + string Email, + string Slug ) : DomainEvent(AggregateId, Version); diff --git a/src/Modules/Providers/Domain/Repositories/IProviderRepository.cs b/src/Modules/Providers/Domain/Repositories/IProviderRepository.cs index 746fc469e..8cb8a118b 100644 --- a/src/Modules/Providers/Domain/Repositories/IProviderRepository.cs +++ b/src/Modules/Providers/Domain/Repositories/IProviderRepository.cs @@ -21,6 +21,14 @@ public interface IProviderRepository /// O prestador encontrado ou null se não existir Task GetByIdAsync(ProviderId id, CancellationToken cancellationToken = default); + /// + /// Busca um prestador de serviços pelo seu slug amigável. + /// + /// Slug amigável do prestador + /// Token de cancelamento da operação + /// O prestador encontrado ou null se não existir + Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default); + /// /// Busca múltiplos prestadores de serviços pelos seus identificadores únicos. /// diff --git a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs index a24e92fe9..2d5c2450a 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs @@ -30,6 +30,11 @@ public void Configure(EntityTypeBuilder builder) .IsRequired() .HasColumnName("name"); + builder.Property(p => p.Slug) + .HasMaxLength(120) + .IsRequired() + .HasColumnName("slug"); + builder.Property(p => p.Type) .HasConversion( type => type.ToString(), @@ -242,6 +247,10 @@ public void Configure(EntityTypeBuilder builder) .IsUnique() .HasDatabaseName("ix_providers_user_id"); + builder.HasIndex(p => p.Slug) + .IsUnique() + .HasDatabaseName("ix_providers_slug"); + builder.HasIndex(p => p.Name) .HasDatabaseName("ix_providers_name"); diff --git a/src/Modules/Providers/Infrastructure/Persistence/Repositories/ProviderRepository.cs b/src/Modules/Providers/Infrastructure/Persistence/Repositories/ProviderRepository.cs index d5489512c..2b7917d4a 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Repositories/ProviderRepository.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Repositories/ProviderRepository.cs @@ -46,6 +46,15 @@ public async Task AddAsync(Provider provider, CancellationToken cancellationToke .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted, cancellationToken); } + /// + /// Busca um prestador de serviços pelo seu slug amigável. + /// + public async Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default) + { + return await GetProvidersQuery() + .FirstOrDefaultAsync(p => p.Slug == slug && !p.IsDeleted, cancellationToken); + } + /// /// Busca múltiplos prestadores de serviços pelos seus identificadores únicos. /// diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs index 7a6d5540a..9643d8e20 100644 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs @@ -42,9 +42,22 @@ public async Task GetMyProfileAsync_WithValidUserId_ShouldDispatchQuery() var userId = Guid.NewGuid(); var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); var providerDto = new ProviderDto( - Guid.NewGuid(), userId, "Test", EProviderType.Individual, null!, - EProviderStatus.Active, EVerificationStatus.Verified, EProviderTier.Standard, - new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + Id: Guid.NewGuid(), + UserId: userId, + Name: "Test", + Slug: "test", + Type: EProviderType.Individual, + BusinessProfile: null!, + Status: EProviderStatus.Active, + VerificationStatus: EVerificationStatus.Verified, + Tier: EProviderTier.Standard, + Documents: new List(), + Qualifications: new List(), + Services: new List(), + CreatedAt: DateTime.UtcNow, + UpdatedAt: null, + IsDeleted: false, + DeletedAt: null); var dispatchResult = Result.Success(providerDto); diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs index 59ae6b54e..b6afc358f 100644 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs @@ -44,9 +44,22 @@ public async Task GetMyStatusAsync_WithValidUserId_ShouldReturnStatus() var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); var providerDto = new ProviderDto( - Guid.NewGuid(), userId, "Test", EProviderType.Individual, null!, - EProviderStatus.Active, EVerificationStatus.Verified, EProviderTier.Gold, - new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + Id: Guid.NewGuid(), + UserId: userId, + Name: "Test", + Slug: "test", + Type: EProviderType.Individual, + BusinessProfile: null!, + Status: EProviderStatus.Active, + VerificationStatus: EVerificationStatus.Verified, + Tier: EProviderTier.Gold, + Documents: new List(), + Qualifications: new List(), + Services: new List(), + CreatedAt: DateTime.UtcNow, + UpdatedAt: null, + IsDeleted: false, + DeletedAt: null); var dispatchResult = Result.Success(providerDto); diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs index 2d307563e..3ca39badb 100644 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs @@ -48,9 +48,22 @@ public async Task UpdateMyProfileAsync_WithValidRequest_ShouldDispatchUpdateComm // Setup Query to return ProviderId var providerDto = new ProviderDto( - providerId, userId, "Test", EProviderType.Individual, null!, - EProviderStatus.Active, EVerificationStatus.Verified, EProviderTier.Standard, - new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + Id: providerId, + UserId: userId, + Name: "Test", + Slug: "test", + Type: EProviderType.Individual, + BusinessProfile: null!, + Status: EProviderStatus.Active, + VerificationStatus: EVerificationStatus.Verified, + Tier: EProviderTier.Standard, + Documents: new List(), + Qualifications: new List(), + Services: new List(), + CreatedAt: DateTime.UtcNow, + UpdatedAt: null, + IsDeleted: false, + DeletedAt: null); _queryDispatcherMock .Setup(x => x.QueryAsync>( diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs index bc2909e0e..bc77f38e3 100644 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs @@ -54,9 +54,22 @@ public async Task UploadDocumentAsync_WithValidRequest_ShouldUploadAndReturnOk() var request = new AddDocumentRequest("12345678909", EDocumentType.CPF); var providerDto = new ProviderDto( - providerId, userId, "Test", EProviderType.Individual, null!, - EProviderStatus.PendingBasicInfo, EVerificationStatus.Pending, EProviderTier.Standard, - new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + Id: providerId, + UserId: userId, + Name: "Test", + Slug: "test", + Type: EProviderType.Individual, + BusinessProfile: null!, + Status: EProviderStatus.PendingBasicInfo, + VerificationStatus: EVerificationStatus.Pending, + Tier: EProviderTier.Standard, + Documents: new List(), + Qualifications: new List(), + Services: new List(), + CreatedAt: DateTime.UtcNow, + UpdatedAt: null, + IsDeleted: false, + DeletedAt: null); // Mock Query (Get provider by user id) _queryDispatcherMock @@ -128,9 +141,22 @@ public async Task UploadDocumentAsync_WhenCommandFails_ShouldReturnBadRequest() var request = new AddDocumentRequest("12345678909", EDocumentType.CPF); var providerDto = new ProviderDto( - providerId, userId, "Test", EProviderType.Individual, null!, - EProviderStatus.PendingBasicInfo, EVerificationStatus.Pending, EProviderTier.Standard, - new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + Id: providerId, + UserId: userId, + Name: "Test", + Slug: "test", + Type: EProviderType.Individual, + BusinessProfile: null!, + Status: EProviderStatus.PendingBasicInfo, + VerificationStatus: EVerificationStatus.Pending, + Tier: EProviderTier.Standard, + Documents: new List(), + Qualifications: new List(), + Services: new List(), + CreatedAt: DateTime.UtcNow, + UpdatedAt: null, + IsDeleted: false, + DeletedAt: null); _queryDispatcherMock.Setup(x => x.QueryAsync>( It.IsAny(), It.IsAny())) diff --git a/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs b/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs index b86798442..70ebef4cf 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs @@ -217,6 +217,7 @@ private static ProviderDto CreateTestProviderDto(Guid id) Id: id, UserId: Guid.NewGuid(), Name: "Test Provider", + Slug: "test-provider", Type: EProviderType.Individual, BusinessProfile: new BusinessProfileDto( LegalName: "Test Provider Legal Name", diff --git a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs index 1ccf3bb61..bb3b6f535 100644 --- a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs +++ b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs @@ -65,6 +65,7 @@ public void Constructor_WithValidParameters_ShouldCreateProvider() provider.Type.Should().Be(type); provider.BusinessProfile.Should().Be(businessProfile); provider.VerificationStatus.Should().Be(EVerificationStatus.Pending); + provider.Slug.Should().Be("john-provider"); provider.IsDeleted.Should().BeFalse(); provider.DeletedAt.Should().BeNull(); provider.Documents.Should().BeEmpty(); @@ -95,6 +96,7 @@ public void Constructor_ShouldRaiseProviderRegisteredDomainEvent() registeredEvent.Name.Should().Be(name); registeredEvent.Type.Should().Be(type); registeredEvent.Email.Should().Be(businessProfile.ContactInfo.Email); + registeredEvent.Slug.Should().Be("john-provider"); } [Theory] @@ -166,6 +168,7 @@ public void UpdateProfile_WithValidParameters_ShouldUpdateProvider() var profileUpdatedEvent = (ProviderProfileUpdatedDomainEvent)updateEvent; profileUpdatedEvent.Name.Should().Be(newName); + profileUpdatedEvent.Slug.Should().Be("updated-provider-name"); profileUpdatedEvent.UpdatedBy.Should().Be(updatedBy); } diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs index e7937258a..7aa7d7f71 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs @@ -60,6 +60,7 @@ public async Task HandleAsync_ShouldPublishIntegrationEvent() 1, "Updated Name", "updated@test.com", + "updated-name", null, new[] { "Name", "Email" } ); @@ -98,6 +99,7 @@ public async Task HandleAsync_WhenMessageBusFails_ShouldThrowException() 1, "Updated Name", "updated@test.com", + "updated-name", null, new[] { "Name", "Email" } ); diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs index 237b2ebe2..7ba1f642d 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs @@ -67,7 +67,8 @@ public async Task HandleAsync_WithValidEvent_ShouldPublishIntegrationEvent() userId, "Provider Test", EProviderType.Individual, - "test@provider.com" + "test@provider.com", + "provider-test" ); // Act @@ -92,7 +93,8 @@ public async Task HandleAsync_WithMissingProvider_ShouldNotPublishEvent() UuidGenerator.NewId(), "Nonexistent Provider", EProviderType.Individual, - "test@provider.com" + "test@provider.com", + "nonexistent-provider" ); // Act @@ -118,7 +120,8 @@ public async Task HandleAsync_WhenCancelled_ShouldPropagateCancellation() UuidGenerator.NewId(), "Provider Test", EProviderType.Individual, - "test@provider.com" + "test@provider.com", + "provider-test" ); using var cts = new CancellationTokenSource(); diff --git a/src/Modules/SearchProviders/Application/DTOs/SearchableProviderDto.cs b/src/Modules/SearchProviders/Application/DTOs/SearchableProviderDto.cs index 7f399f6d0..ae6297ece 100644 --- a/src/Modules/SearchProviders/Application/DTOs/SearchableProviderDto.cs +++ b/src/Modules/SearchProviders/Application/DTOs/SearchableProviderDto.cs @@ -13,6 +13,7 @@ public sealed record SearchableProviderDto( int TotalReviews, ESubscriptionTier SubscriptionTier, IReadOnlyList ServiceIds, + string Slug, string? Description = null, double? DistanceInKm = null, string? City = null, diff --git a/src/Modules/SearchProviders/Application/Handlers/SearchProvidersQueryHandler.cs b/src/Modules/SearchProviders/Application/Handlers/SearchProvidersQueryHandler.cs index 9cab8cb2f..d4f319e41 100644 --- a/src/Modules/SearchProviders/Application/Handlers/SearchProvidersQueryHandler.cs +++ b/src/Modules/SearchProviders/Application/Handlers/SearchProvidersQueryHandler.cs @@ -87,6 +87,7 @@ public async Task>> HandleAsync( TotalReviews: p.TotalReviews, SubscriptionTier: p.SubscriptionTier, ServiceIds: p.ServiceIds, + Slug: p.Slug, Description: p.Description, DistanceInKm: searchResult.DistancesInKm[index], City: p.City, diff --git a/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs b/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs index 8b7d6dbd2..32cbb54c6 100644 --- a/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs +++ b/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs @@ -165,7 +165,7 @@ public async Task IndexProviderAsync(Guid providerId, CancellationToken // Atualizar informações do provider existente var location = new GeoPoint(providerData.Latitude, providerData.Longitude); - existing.UpdateBasicInfo(providerData.Name, providerData.Description, providerData.City, providerData.State); + existing.UpdateBasicInfo(providerData.Name, providerData.Slug, providerData.Description, providerData.City, providerData.State); existing.UpdateLocation(location); existing.UpdateRating(providerData.AverageRating, providerData.TotalReviews); existing.UpdateSubscriptionTier(ToDomainTier(providerData.SubscriptionTier)); @@ -187,6 +187,7 @@ public async Task IndexProviderAsync(Guid providerId, CancellationToken var searchableProvider = SearchableProvider.Create( providerId: providerData.ProviderId, name: providerData.Name, + slug: providerData.Slug, location: location, subscriptionTier: ToDomainTier(providerData.SubscriptionTier), description: providerData.Description, diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 26d67c805..3a73986c1 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -21,6 +21,11 @@ public sealed class SearchableProvider : AggregateRoot /// public string Name { get; private set; } = string.Empty; + /// + /// Slug amigável para URL do provedor. + /// + public string Slug { get; private set; } = string.Empty; + /// /// Localização geográfica do provedor. /// @@ -95,6 +100,7 @@ private SearchableProvider( public static SearchableProvider Create( Guid providerId, string name, + string slug, GeoPoint location, ESubscriptionTier subscriptionTier = ESubscriptionTier.Free, string? description = null, @@ -106,6 +112,11 @@ public static SearchableProvider Create( throw new ArgumentException("Provider name cannot be empty.", nameof(name)); } + if (string.IsNullOrWhiteSpace(slug)) + { + throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); + } + ArgumentNullException.ThrowIfNull(location); var searchableProvider = new SearchableProvider( @@ -115,6 +126,7 @@ public static SearchableProvider Create( location, subscriptionTier) { + Slug = slug.Trim().ToLowerInvariant(), Description = description?.Trim(), City = city?.Trim(), State = state?.Trim() @@ -136,6 +148,7 @@ internal static SearchableProvider Reconstitute( Guid id, Guid providerId, string name, + string slug, GeoPoint location, ESubscriptionTier subscriptionTier, decimal averageRating, @@ -153,6 +166,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { + Slug = slug, Description = description, City = city, State = state, @@ -168,14 +182,20 @@ internal static SearchableProvider Reconstitute( /// /// Atualiza as informações básicas do provedor. /// - public void UpdateBasicInfo(string name, string? description, string? city, string? state) + public void UpdateBasicInfo(string name, string slug, string? description, string? city, string? state) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("Provider name cannot be empty.", nameof(name)); } + if (string.IsNullOrWhiteSpace(slug)) + { + throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); + } + Name = name.Trim(); + Slug = slug.Trim().ToLowerInvariant(); Description = description?.Trim(); City = city?.Trim(); State = state?.Trim(); diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs index 04120a7de..e1d6eaff7 100644 --- a/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs @@ -41,6 +41,11 @@ public void Configure(EntityTypeBuilder builder) .HasMaxLength(200) .HasColumnName("name"); + builder.Property(p => p.Slug) + .IsRequired() + .HasMaxLength(200) + .HasColumnName("slug"); + builder.Property(p => p.Description) .HasMaxLength(1000) .HasColumnName("description"); diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/DTOs/ProviderSearchResultDto.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/DTOs/ProviderSearchResultDto.cs index 500f444ee..36639c909 100644 --- a/src/Modules/SearchProviders/Infrastructure/Persistence/DTOs/ProviderSearchResultDto.cs +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/DTOs/ProviderSearchResultDto.cs @@ -9,6 +9,7 @@ internal sealed class ProviderSearchResultDto public Guid Id { get; set; } public Guid ProviderId { get; set; } public string Name { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; public string? Description { get; set; } public double Latitude { get; set; } public double Longitude { get; set; } diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs index ac1a3aa72..5f8247378 100644 --- a/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs @@ -95,6 +95,7 @@ public async Task SearchAsync( id AS Id, provider_id AS ProviderId, name AS Name, + slug AS Slug, description AS Description, ST_Y(location::geometry) AS Latitude, ST_X(location::geometry) AS Longitude, @@ -226,6 +227,7 @@ private static SearchableProvider MapToEntity(ProviderSearchResultDto dto) id: dto.Id, providerId: dto.ProviderId, name: dto.Name, + slug: dto.Slug, location: new GeoPoint(dto.Latitude, dto.Longitude), subscriptionTier: (ESubscriptionTier)dto.SubscriptionTier, averageRating: dto.AverageRating, diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 414d42811..43aafce06 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -162,13 +162,14 @@ protected SearchableProvider CreateTestSearchableProvider( var location = new GeoPoint(latitude, longitude); var provider = SearchableProvider.Create( - providerId, - name, - location, - tier, - description, - city, - state); + providerId: providerId, + name: name, + slug: name.ToLower().Replace(" ", "-"), + location: location, + subscriptionTier: tier, + description: description, + city: city, + state: state); return provider; } @@ -189,13 +190,14 @@ protected SearchableProvider CreateTestSearchableProviderWithProviderId( var location = new GeoPoint(latitude, longitude); var provider = SearchableProvider.Create( - providerId, - name, - location, - tier, - description, - city, - state); + providerId: providerId, + name: name, + slug: name.ToLower().Replace(" ", "-"), + location: location, + subscriptionTier: tier, + description: description, + city: city, + state: state); return provider; } diff --git a/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs b/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs index 2faf1494c..658335b31 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs @@ -355,10 +355,11 @@ public async Task HandleAsync_ShouldMapDistanceCorrectly() RadiusInKm: 500); var provider = SearchableProvider.Create( - Guid.NewGuid(), - "Test Provider", - new GeoPoint(-22.9068, -43.1729), // Rio de Janeiro - ESubscriptionTier.Free); + providerId: Guid.NewGuid(), + name: "Test Provider", + slug: "test-provider", + location: new GeoPoint(-22.9068, -43.1729), // Rio de Janeiro + subscriptionTier: ESubscriptionTier.Free); var distance = provider.CalculateDistanceToInKm(searchLocation); _repositoryMock @@ -392,14 +393,16 @@ private static List CreateTestProviders(int count) var providers = new List(); for (int i = 0; i < count; i++) { + var name = $"Provider {i + 1}"; providers.Add(SearchableProvider.Create( - Guid.NewGuid(), - $"Provider {i + 1}", - new GeoPoint(-23.5505 + i * 0.01, -46.6333 + i * 0.01), - (ESubscriptionTier)(i % 4), - $"Description {i + 1}", - "São Paulo", - "SP")); + providerId: Guid.NewGuid(), + name: name, + slug: name.ToLower().Replace(" ", "-"), + location: new GeoPoint(-23.5505 + i * 0.01, -46.6333 + i * 0.01), + subscriptionTier: (ESubscriptionTier)(i % 4), + description: $"Description {i + 1}", + city: "São Paulo", + state: "SP")); } return providers; } diff --git a/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs b/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs index 8647bfb59..0b85a999b 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs @@ -158,6 +158,7 @@ public async Task SearchProvidersAsync_WithValidParameters_ShouldReturnResults() new( ProviderId: providerId, Name: "Provider 1", + Slug: "provider-1", Location: new LocationDto( Latitude: -23.5, Longitude: -46.6), @@ -323,23 +324,25 @@ public async Task IndexProviderAsync_WithNewProvider_ShouldCreateAndIndex() { // Arrange var providerId = Guid.NewGuid(); - var providerData = new ModuleProviderIndexingDto( + var dto = new ModuleProviderIndexingDto( ProviderId: providerId, - Name: "New Provider", - Latitude: -23.561414, - Longitude: -46.656559, - ServiceIds: new[] { Guid.NewGuid() }, + Name: "Test Provider", + Slug: "test-provider", + Latitude: -23.55, + Longitude: -46.63, + ServiceIds: new List { Guid.NewGuid() }, AverageRating: 4.5m, TotalReviews: 10, - SubscriptionTier: ESubscriptionTier.Gold, + SubscriptionTier: MeAjudaAi.Contracts.Modules.SearchProviders.Enums.ESubscriptionTier.Gold, IsActive: true, - Description: "Test description", + Description: "A test provider", City: "São Paulo", - State: "SP"); + State: "SP" + ); _providersApiMock .Setup(x => x.GetProviderForIndexingAsync(providerId, It.IsAny())) - .ReturnsAsync(Result.Success(providerData)); + .ReturnsAsync(Result.Success(dto)); _repositoryMock .Setup(x => x.GetByProviderIdAsync(providerId, It.IsAny())) @@ -360,23 +363,25 @@ public async Task IndexProviderAsync_WithExistingProvider_ShouldUpdate() // Arrange var providerId = Guid.NewGuid(); var existingProvider = SearchableProvider.Create( - providerId, - "Old Name", - new GeoPoint(-23.5, -46.6), - DomainEnums.ESubscriptionTier.Free, - "Old description", - "Old City", - "OC"); + providerId: providerId, + name: "Old Name", + slug: "old-name", + location: new GeoPoint(-23.5, -46.6), + subscriptionTier: DomainEnums.ESubscriptionTier.Free, + description: "Old description", + city: "Old City", + state: "OC"); var updatedData = new ModuleProviderIndexingDto( ProviderId: providerId, Name: "Updated Provider", + Slug: "updated-provider", Latitude: -23.561414, Longitude: -46.656559, ServiceIds: new[] { Guid.NewGuid(), Guid.NewGuid() }, AverageRating: 4.8m, TotalReviews: 25, - SubscriptionTier: ESubscriptionTier.Platinum, + SubscriptionTier: MeAjudaAi.Contracts.Modules.SearchProviders.Enums.ESubscriptionTier.Platinum, IsActive: true, Description: "Updated description", City: "São Paulo", @@ -468,12 +473,13 @@ public async Task IndexProviderAsync_WhenInactiveProvider_ShouldDeactivateInInde var providerData = new ModuleProviderIndexingDto( ProviderId: providerId, Name: "Inactive Provider", + Slug: "inactive-provider", Latitude: -23.5, Longitude: -46.6, ServiceIds: Array.Empty(), AverageRating: 0, TotalReviews: 0, - SubscriptionTier: ESubscriptionTier.Free, + SubscriptionTier: MeAjudaAi.Contracts.Modules.SearchProviders.Enums.ESubscriptionTier.Free, IsActive: false, Description: "Test", City: "São Paulo", @@ -530,13 +536,14 @@ public async Task RemoveProviderAsync_WithExistingProvider_ShouldRemoveFromIndex // Arrange var providerId = Guid.NewGuid(); var existingProvider = SearchableProvider.Create( - providerId, - "Provider to Remove", - new GeoPoint(-23.5, -46.6), - DomainEnums.ESubscriptionTier.Free, - "Test", - "São Paulo", - "SP"); + providerId: providerId, + name: "Provider to Remove", + slug: "provider-to-remove", + location: new GeoPoint(-23.5, -46.6), + subscriptionTier: DomainEnums.ESubscriptionTier.Free, + description: "Test", + city: "São Paulo", + state: "SP"); _repositoryMock .Setup(x => x.GetByProviderIdAsync(providerId, It.IsAny())) diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index 5aa843c32..06a6a84ca 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -26,6 +26,7 @@ public void Create_WithValidData_ShouldCreateSearchableProvider() var provider = SearchableProvider.Create( providerId, name, + "test-provider", location, subscriptionTier, "Test description", @@ -55,7 +56,7 @@ public void Create_WithEmptyName_ShouldThrowArgumentException() var location = new GeoPoint(-23.5505, -46.6333); // Act - var act = () => SearchableProvider.Create(providerId, "", location); + var act = () => SearchableProvider.Create(providerId, "", "slug", location); // Assert act.Should().Throw() @@ -70,7 +71,7 @@ public void Create_WithWhitespaceName_ShouldThrowArgumentException() var location = new GeoPoint(-23.5505, -46.6333); // Act - var act = () => SearchableProvider.Create(providerId, " ", location); + var act = () => SearchableProvider.Create(providerId, " ", "slug", location); // Assert act.Should().Throw(); @@ -87,7 +88,7 @@ public void UpdateBasicInfo_WithValidData_ShouldUpdateFields() var newState = "RJ"; // Act - provider.UpdateBasicInfo(newName, newDescription, newCity, newState); + provider.UpdateBasicInfo(newName, "updated-provider", newDescription, newCity, newState); // Assert provider.Name.Should().Be(newName); @@ -104,7 +105,7 @@ public void UpdateBasicInfo_WithEmptyName_ShouldThrowArgumentException() var provider = CreateValidProvider(); // Act - var act = () => provider.UpdateBasicInfo("", "desc", "city", "ST"); + var act = () => provider.UpdateBasicInfo("", "slug", "desc", "city", "ST"); // Assert act.Should().Throw(); @@ -296,7 +297,7 @@ public void CalculateDistanceToInKm_SameLocation_ShouldReturnZero() { // Arrange var location = new GeoPoint(-23.5505, -46.6333); - var provider = SearchableProvider.Create(Guid.NewGuid(), "Test", location); + var provider = SearchableProvider.Create(Guid.NewGuid(), "Test", "test", location); // Act var distance = provider.CalculateDistanceToInKm(location); @@ -310,6 +311,7 @@ private static SearchableProvider CreateValidProvider() return SearchableProvider.Create( Guid.NewGuid(), "Test Provider", + "test-provider", new GeoPoint(-23.5505, -46.6333), // São Paulo ESubscriptionTier.Free, "Test description", diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs index 8cdc54e13..4f5bf7a55 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs @@ -185,14 +185,16 @@ private IReadOnlyList CreateProviders(int count) var providerId = Guid.NewGuid(); var location = new GeoPoint(-23.5505 + i * 0.1, -46.6333 + i * 0.1); + var providerName = _faker.Company.CompanyName(); var provider = SearchableProvider.Create( - providerId, - _faker.Person.FullName, - location, - _faker.Random.Enum(), - _faker.Lorem.Sentence(), - _faker.Address.City(), - _faker.Address.StateAbbr() + providerId: providerId, + name: providerName, + slug: providerName.ToLower().Replace(" ", "-"), + location: location, + subscriptionTier: _faker.Random.Enum(), + description: _faker.Lorem.Sentence(), + city: _faker.Address.City(), + state: _faker.Address.StateAbbr() ); providers.Add(provider); diff --git a/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs index 5c2fb386a..fcc3d4088 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs @@ -50,14 +50,20 @@ private SearchableProvider CreateTestProvider( longitude ?? _faker.Address.Longitude() ); + var actualProviderId = providerId ?? UuidGenerator.NewId(); + var actualName = name ?? _faker.Company.CompanyName(); + var actualSlug = actualName.ToLower().Replace(" ", "-"); + var actualSubscriptionTier = tier ?? _faker.Random.Enum(); + return SearchableProvider.Create( - providerId ?? UuidGenerator.NewId(), - name ?? _faker.Company.CompanyName(), - location, - tier ?? ESubscriptionTier.Free, - _faker.Lorem.Sentence(), - _faker.Address.City(), - _faker.Address.StateAbbr() + providerId: actualProviderId, + name: actualName, + slug: actualSlug, + location: location, + subscriptionTier: actualSubscriptionTier, + description: _faker.Lorem.Sentence(), + city: _faker.Address.City(), + state: _faker.Address.StateAbbr() ); } @@ -187,7 +193,12 @@ public async Task UpdateAsync_WithValidProvider_ShouldMarkAsModified() _context.Entry(provider).State = EntityState.Detached; // Modify provider - provider.UpdateBasicInfo("Updated Name", "Updated Description", "São Paulo", "SP"); + provider.UpdateBasicInfo( + name: "Updated Name", + slug: "updated-name", + description: "Updated Description", + city: "São Paulo", + state: "SP"); // Act await _repository.UpdateAsync(provider); @@ -207,7 +218,12 @@ public async Task UpdateAsync_WithValidProvider_ShouldPersistChanges() await _context.SaveChangesAsync(); // Modify provider - provider.UpdateBasicInfo("Updated Name", "Updated Description", "Rio de Janeiro", "RJ"); + provider.UpdateBasicInfo( + name: "Updated Name", + slug: "updated-name", + description: "Updated Description", + city: "Rio de Janeiro", + state: "RJ"); // Act await _repository.UpdateAsync(provider); diff --git a/src/Shared/Utilities/SlugHelper.cs b/src/Shared/Utilities/SlugHelper.cs new file mode 100644 index 000000000..8cab9d559 --- /dev/null +++ b/src/Shared/Utilities/SlugHelper.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace MeAjudaAi.Shared.Utilities; + +/// +/// Helper para geração de slugs amigáveis para URL +/// +public static partial class SlugHelper +{ + [GeneratedRegex(@"[^a-z0-9\s-]")] + private static partial Regex NonAlphanumericRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex MultipleWhitespaceRegex(); + + [GeneratedRegex(@"-+")] + private static partial Regex MultipleHifenRegex(); + + /// + /// Gera um slug a partir de um texto + /// + /// Texto original + /// Slug formatado + public static string Generate(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + // 1. Lowercase e Normalização + string slug = text.ToLowerInvariant(); + slug = RemoveDiacritics(slug); + + // 2. Remover caracteres não alfanuméricos (exceto espaços e hifens) + slug = NonAlphanumericRegex().Replace(slug, ""); + + // 3. Substituir espaços por hifens + slug = MultipleWhitespaceRegex().Replace(slug, "-"); + + // 4. Remover hifens duplicados + slug = MultipleHifenRegex().Replace(slug, "-"); + + // 5. Trim hifens das extremidades + return slug.Trim('-'); + } + + private static string RemoveDiacritics(string text) + { + var normalizedString = text.Normalize(NormalizationForm.FormD); + var stringBuilder = new StringBuilder(); + + foreach (var c in normalizedString) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + { + stringBuilder.Append(c); + } + } + + return stringBuilder.ToString().Normalize(NormalizationForm.FormC); + } +} diff --git a/src/Web/MeAjudaAi.Web.Admin/packages.lock.json b/src/Web/MeAjudaAi.Web.Admin/packages.lock.json index e4c44fa1c..1adbc52d3 100644 --- a/src/Web/MeAjudaAi.Web.Admin/packages.lock.json +++ b/src/Web/MeAjudaAi.Web.Admin/packages.lock.json @@ -85,6 +85,12 @@ "resolved": "10.0.5", "contentHash": "tZwWmlI7WWT1KnDA/S5tkqHd2ZXxnYL0VLqbPAmDVppVlIqqQZLP3+OiW+IgcVSsAgi6WMlGcAd3DrhpFHw/uw==" }, + "Microsoft.DotNet.HotReload.WebAssembly.Browser": { + "type": "Direct", + "requested": "[10.0.104, )", + "resolved": "10.0.104", + "contentHash": "OkWFygvFdm9emwxRW5ahcsU6saKO9EOYCFVNEXWl+23A4ssZy40VCQuqPhyurU7IyaPEhgA4iXh0/o7fhyNngA==" + }, "Microsoft.Extensions.Http.Resilience": { "type": "Direct", "requested": "[10.4.0, )", diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs new file mode 100644 index 000000000..d6cfca5cf --- /dev/null +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; +using MeAjudaAi.Shared.Utilities; + +namespace MeAjudaAi.Shared.Tests.Unit.Utilities; + +public class SlugHelperTests +{ + [Theory] + [InlineData("Clinica Auto Muriae", "clinica-auto-muriae")] + [InlineData("João & Maria Serviços", "joao-maria-servicos")] + [InlineData(" Espaços Extras ", "espacos-extras")] + [InlineData("C# & .NET Developer", "c-net-developer")] + [InlineData("Acentuação: áéíóú àèìòù âêîôû äëïöü ãõ ñ ç", "acentuacao-aeiou-aeiou-aeiou-aeiou-ao-n-c")] + [InlineData("Maçã Verde", "maca-verde")] + [InlineData("UPPERCASE text", "uppercase-text")] + [InlineData("multiple---hifens", "multiple-hifens")] + [InlineData("!@#$%^&*()_+", "")] + [InlineData(null, "")] + [InlineData("", "")] + public void Generate_ShouldReturnExpectedSlug(string input, string expected) + { + // Act + var result = SlugHelper.Generate(input); + + // Assert + result.Should().Be(expected); + } +} diff --git a/tests/MeAjudaAi.Web.Admin.Tests/Pages/ProvidersPageTests.cs b/tests/MeAjudaAi.Web.Admin.Tests/Pages/ProvidersPageTests.cs index 202a88628..ba80f99e8 100644 --- a/tests/MeAjudaAi.Web.Admin.Tests/Pages/ProvidersPageTests.cs +++ b/tests/MeAjudaAi.Web.Admin.Tests/Pages/ProvidersPageTests.cs @@ -60,6 +60,7 @@ public async Task Providers_Page_Should_Display_Providers_In_DataGrid() var testProvider = new ModuleProviderDto( Guid.NewGuid(), "Fornecedor Teste", + "fornecedor-teste", "teste@exemplo.com", "12345678901", "Individual", diff --git a/tests/MeAjudaAi.Web.Admin.Tests/packages.lock.json b/tests/MeAjudaAi.Web.Admin.Tests/packages.lock.json index 8f6134a34..a5bd6d95e 100644 --- a/tests/MeAjudaAi.Web.Admin.Tests/packages.lock.json +++ b/tests/MeAjudaAi.Web.Admin.Tests/packages.lock.json @@ -183,8 +183,8 @@ }, "Microsoft.DotNet.HotReload.WebAssembly.Browser": { "type": "Transitive", - "resolved": "10.0.103", - "contentHash": "tIq6T4oyBMjxxxllslJu2fOJqEj8SOW29IfWqHYxubreOVlFWnxPN2PjDMuwoHjkrOnPKyuqoNcVYh5YOZcs0Q==" + "resolved": "10.0.104", + "contentHash": "OkWFygvFdm9emwxRW5ahcsU6saKO9EOYCFVNEXWl+23A4ssZy40VCQuqPhyurU7IyaPEhgA4iXh0/o7fhyNngA==" }, "Microsoft.Extensions.AmbientMetadata.Application": { "type": "Transitive", @@ -563,7 +563,7 @@ "MeAjudaAi.Contracts": "[1.0.0, )", "Microsoft.AspNetCore.Components.WebAssembly": "[10.0.5, )", "Microsoft.AspNetCore.Components.WebAssembly.Authentication": "[10.0.5, )", - "Microsoft.DotNet.HotReload.WebAssembly.Browser": "[10.0.103, )", + "Microsoft.DotNet.HotReload.WebAssembly.Browser": "[10.0.104, )", "Microsoft.Extensions.Http.Resilience": "[10.4.0, )", "MudBlazor": "[8.15.0, )", "Polly": "[8.6.6, )", From 4b77f329ac890a0ba88e4ac3428856d8b3b4e4bc Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Tue, 17 Mar 2026 11:34:27 -0300 Subject: [PATCH 02/24] feat: Introduce Provider entity, SearchProviders module, and slug generation utility. --- prompts/design-react-project.md | 2 +- .../DTOs/ModuleSearchableProviderDto.cs | 1 + .../Providers/Domain/Entities/Provider.cs | 6 +- .../Events/Mappers/ProviderEventMappers.cs | 6 +- .../Unit/Domain/Entities/ProviderTests.cs | 6 +- ...erProfileUpdatedDomainEventHandlerTests.cs | 11 +-- ...oviderRegisteredDomainEventHandlerTests.cs | 5 +- .../ModuleApi/SearchProvidersModuleApi.cs | 1 + .../Domain/Entities/SearchableProvider.cs | 2 +- .../SearchableProviderConfiguration.cs | 2 +- .../SearchProvidersIntegrationTestBase.cs | 5 +- .../SearchProvidersQueryHandlerTests.cs | 3 +- .../SearchProvidersModuleApiTests.cs | 1 + .../Entities/SearchableProviderTests.cs | 70 +++++++++++++++++++ .../Unit/Domain/Models/SearchResultTests.cs | 3 +- .../SearchableProviderRepositoryTests.cs | 2 +- .../ProviderProfileUpdatedIntegrationEvent.cs | 3 +- .../ProviderRegisteredIntegrationEvent.cs | 3 +- src/Shared/Utilities/SlugHelper.cs | 9 +++ .../Unit/Utilities/SlugHelperTests.cs | 1 + 20 files changed, 118 insertions(+), 24 deletions(-) diff --git a/prompts/design-react-project.md b/prompts/design-react-project.md index 232907102..b114b027f 100644 --- a/prompts/design-react-project.md +++ b/prompts/design-react-project.md @@ -135,7 +135,7 @@ export function CardContent({ className, ...props }: ComponentProps<"div">) { ## Cores (CSS Variables) -``` +```css bg-surface, bg-surface-raised → fundos bg-primary, bg-secondary, bg-muted → ações/estados bg-destructive → erros/danger diff --git a/src/Contracts/Contracts/Modules/SearchProviders/DTOs/ModuleSearchableProviderDto.cs b/src/Contracts/Contracts/Modules/SearchProviders/DTOs/ModuleSearchableProviderDto.cs index b6fa8ae9d..d887104e8 100644 --- a/src/Contracts/Contracts/Modules/SearchProviders/DTOs/ModuleSearchableProviderDto.cs +++ b/src/Contracts/Contracts/Modules/SearchProviders/DTOs/ModuleSearchableProviderDto.cs @@ -8,6 +8,7 @@ namespace MeAjudaAi.Contracts.Modules.SearchProviders.DTOs; public sealed record ModuleSearchableProviderDto( Guid ProviderId, string Name, + string Slug, ModuleLocationDto Location, decimal AverageRating, int TotalReviews, diff --git a/src/Modules/Providers/Domain/Entities/Provider.cs b/src/Modules/Providers/Domain/Entities/Provider.cs index 76a1cbe40..211489924 100644 --- a/src/Modules/Providers/Domain/Entities/Provider.cs +++ b/src/Modules/Providers/Domain/Entities/Provider.cs @@ -137,7 +137,7 @@ public Provider( UserId = userId; Name = name.Trim(); - Slug = SlugHelper.Generate(Name); + Slug = SlugHelper.GenerateWithSuffix(Name, Id.Value.ToString("N")[..8]); Type = type; BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; @@ -172,7 +172,7 @@ public Provider( UserId = userId; Name = name.Trim(); - Slug = SlugHelper.Generate(Name); + Slug = SlugHelper.GenerateWithSuffix(Name, Id.Value.ToString("N")[..8]); Type = type; BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; @@ -213,7 +213,7 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? { updatedFields.Add("Name"); Name = newName; - Slug = SlugHelper.Generate(Name); + Slug = SlugHelper.GenerateWithSuffix(Name, Id.Value.ToString("N")[..8]); updatedFields.Add("Slug"); } diff --git a/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs b/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs index 723d0618a..e4449e8bf 100644 --- a/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs +++ b/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs @@ -22,7 +22,8 @@ public static ProviderRegisteredIntegrationEvent ToIntegrationEvent(this Provide Name: domainEvent.Name, ProviderType: domainEvent.Type.ToString(), Email: domainEvent.Email, - RegisteredAt: DateTime.UtcNow + RegisteredAt: DateTime.UtcNow, + Slug: domainEvent.Slug ); } @@ -70,7 +71,8 @@ public static ProviderProfileUpdatedIntegrationEvent ToIntegrationEvent(this Pro Name: domainEvent.Name, UpdatedFields: updatedFields, UpdatedBy: domainEvent.UpdatedBy, - NewEmail: domainEvent.Email + NewEmail: domainEvent.Email, + Slug: domainEvent.Slug ); } diff --git a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs index bb3b6f535..273729cc9 100644 --- a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs +++ b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs @@ -65,7 +65,7 @@ public void Constructor_WithValidParameters_ShouldCreateProvider() provider.Type.Should().Be(type); provider.BusinessProfile.Should().Be(businessProfile); provider.VerificationStatus.Should().Be(EVerificationStatus.Pending); - provider.Slug.Should().Be("john-provider"); + provider.Slug.Should().StartWith("john-provider-"); provider.IsDeleted.Should().BeFalse(); provider.DeletedAt.Should().BeNull(); provider.Documents.Should().BeEmpty(); @@ -96,7 +96,7 @@ public void Constructor_ShouldRaiseProviderRegisteredDomainEvent() registeredEvent.Name.Should().Be(name); registeredEvent.Type.Should().Be(type); registeredEvent.Email.Should().Be(businessProfile.ContactInfo.Email); - registeredEvent.Slug.Should().Be("john-provider"); + registeredEvent.Slug.Should().StartWith("john-provider-"); } [Theory] @@ -168,7 +168,7 @@ public void UpdateProfile_WithValidParameters_ShouldUpdateProvider() var profileUpdatedEvent = (ProviderProfileUpdatedDomainEvent)updateEvent; profileUpdatedEvent.Name.Should().Be(newName); - profileUpdatedEvent.Slug.Should().Be("updated-provider-name"); + profileUpdatedEvent.Slug.Should().StartWith("updated-provider-name-"); profileUpdatedEvent.UpdatedBy.Should().Be(updatedBy); } diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs index 7aa7d7f71..090da17ba 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs @@ -41,8 +41,8 @@ public ProviderProfileUpdatedDomainEventHandlerTests() } /// - /// Verifies that HandleAsync publishes a ProviderProfileUpdatedIntegrationEvent with correct provider details - /// when processing a valid profile update domain event. + /// Verifica que HandleAsync publica um ProviderProfileUpdatedIntegrationEvent + /// com os detalhes corretos do provider ao processar um evento de atualização de perfil válido. /// [Fact] public async Task HandleAsync_ShouldPublishIntegrationEvent() @@ -73,15 +73,16 @@ public async Task HandleAsync_ShouldPublishIntegrationEvent() x => x.PublishAsync( It.Is(e => e.ProviderId == providerId.Value && - e.Name == "Updated Name"), + e.Name == "Updated Name" && + e.Slug == "updated-name"), It.IsAny(), It.IsAny()), Times.Once); } /// - /// Verifies that when message bus publishing fails, the handler logs an error containing - /// "Error handling ProviderProfileUpdatedDomainEvent" and re-throws the exception. + /// Verifica que quando a publicação no message bus falha, o handler registra + /// um erro contendo "Error handling ProviderProfileUpdatedDomainEvent" e relança a exceção. /// [Fact] public async Task HandleAsync_WhenMessageBusFails_ShouldThrowException() diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs index 7ba1f642d..6f67198a0 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs @@ -5,6 +5,7 @@ using MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; using MeAjudaAi.Shared.Messaging; +using MeAjudaAi.Shared.Messaging.Messages.Providers; using MeAjudaAi.Shared.Utilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; @@ -77,7 +78,9 @@ public async Task HandleAsync_WithValidEvent_ShouldPublishIntegrationEvent() // Assert _messageBusMock.Verify( x => x.PublishAsync( - It.Is(e => e.GetType().Name == "ProviderRegisteredIntegrationEvent"), + It.Is(e => + e.Slug == "provider-test" && + e.Name == "Provider Test"), It.IsAny(), It.IsAny()), Times.Once); diff --git a/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs b/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs index 32cbb54c6..9c8d1ac8d 100644 --- a/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs +++ b/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs @@ -114,6 +114,7 @@ public async Task> SearchProvidersAsync( Items: result.Value!.Items.Select(p => new ModuleSearchableProviderDto( ProviderId: p.ProviderId, Name: p.Name, + Slug: p.Slug, Location: new ModuleLocationDto( Latitude: p.Location.Latitude, Longitude: p.Location.Longitude), diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 3a73986c1..614f719ce 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -166,7 +166,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = slug, + Slug = slug?.Trim().ToLowerInvariant() ?? string.Empty, Description = description, City = city, State = state, diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs index e1d6eaff7..02b9b3ece 100644 --- a/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs @@ -43,7 +43,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(p => p.Slug) .IsRequired() - .HasMaxLength(200) + .HasMaxLength(120) .HasColumnName("slug"); builder.Property(p => p.Description) diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 43aafce06..52be74493 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MeAjudaAi.Shared.Utilities; using Testcontainers.PostgreSql; namespace MeAjudaAi.Modules.SearchProviders.Tests.Integration; @@ -164,7 +165,7 @@ protected SearchableProvider CreateTestSearchableProvider( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: name.ToLower().Replace(" ", "-"), + slug: SlugHelper.Generate(name), location: location, subscriptionTier: tier, description: description, @@ -192,7 +193,7 @@ protected SearchableProvider CreateTestSearchableProviderWithProviderId( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: name.ToLower().Replace(" ", "-"), + slug: SlugHelper.Generate(name), location: location, subscriptionTier: tier, description: description, diff --git a/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs b/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs index 658335b31..df9a4573b 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs @@ -8,6 +8,7 @@ using MeAjudaAi.Modules.SearchProviders.Domain.Repositories; using MeAjudaAi.Modules.SearchProviders.Domain.ValueObjects; using MeAjudaAi.Shared.Geolocation; +using MeAjudaAi.Shared.Utilities; using Microsoft.Extensions.Logging; using Moq; @@ -397,7 +398,7 @@ private static List CreateTestProviders(int count) providers.Add(SearchableProvider.Create( providerId: Guid.NewGuid(), name: name, - slug: name.ToLower().Replace(" ", "-"), + slug: SlugHelper.Generate(name), location: new GeoPoint(-23.5505 + i * 0.01, -46.6333 + i * 0.01), subscriptionTier: (ESubscriptionTier)(i % 4), description: $"Description {i + 1}", diff --git a/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs b/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs index 0b85a999b..52269ed71 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs @@ -206,6 +206,7 @@ public async Task SearchProvidersAsync_WithValidParameters_ShouldReturnResults() var provider = result.Value.Items[0]; provider.ProviderId.Should().Be(providerId); provider.Name.Should().Be("Provider 1"); + provider.Slug.Should().Be("provider-1"); provider.Description.Should().Be("Test provider"); provider.AverageRating.Should().Be(4.5m); provider.TotalReviews.Should().Be(10); diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index 06a6a84ca..0c2bb431b 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -77,6 +77,76 @@ public void Create_WithWhitespaceName_ShouldThrowArgumentException() act.Should().Throw(); } + [Fact] + public void Create_WithEmptySlug_ShouldThrowArgumentException() + { + // Arrange + var providerId = Guid.NewGuid(); + var location = new GeoPoint(-23.5505, -46.6333); + + // Act + var act = () => SearchableProvider.Create(providerId, "Valid Name", "", location); + + // Assert + act.Should().Throw() + .WithMessage("*Provider slug cannot be empty*"); + } + + [Fact] + public void Create_WithWhitespaceSlug_ShouldThrowArgumentException() + { + // Arrange + var providerId = Guid.NewGuid(); + var location = new GeoPoint(-23.5505, -46.6333); + + // Act + var act = () => SearchableProvider.Create(providerId, "Valid Name", " ", location); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Create_WithUppercaseSlug_ShouldNormalizeToLowercase() + { + // Arrange + var providerId = Guid.NewGuid(); + var location = new GeoPoint(-23.5505, -46.6333); + + // Act + var provider = SearchableProvider.Create(providerId, "Valid Name", "My-SLUG", location); + + // Assert + provider.Slug.Should().Be("my-slug"); + } + + [Fact] + public void UpdateBasicInfo_WithEmptySlug_ShouldThrowArgumentException() + { + // Arrange + var provider = CreateValidProvider(); + + // Act + var act = () => provider.UpdateBasicInfo("Valid Name", "", "desc", "city", "ST"); + + // Assert + act.Should().Throw() + .WithMessage("*Provider slug cannot be empty*"); + } + + [Fact] + public void UpdateBasicInfo_WithUppercaseSlug_ShouldNormalizeToLowercase() + { + // Arrange + var provider = CreateValidProvider(); + + // Act + provider.UpdateBasicInfo("Valid Name", "MY-SLUG", "desc", "city", "ST"); + + // Assert + provider.Slug.Should().Be("my-slug"); + } + [Fact] public void UpdateBasicInfo_WithValidData_ShouldUpdateFields() { diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs index 4f5bf7a55..62a505062 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs @@ -5,6 +5,7 @@ using MeAjudaAi.Modules.SearchProviders.Domain.Models; using MeAjudaAi.Modules.SearchProviders.Domain.ValueObjects; using MeAjudaAi.Shared.Geolocation; +using MeAjudaAi.Shared.Utilities; namespace MeAjudaAi.Modules.SearchProviders.Tests.Unit.Domain.Models; @@ -189,7 +190,7 @@ private IReadOnlyList CreateProviders(int count) var provider = SearchableProvider.Create( providerId: providerId, name: providerName, - slug: providerName.ToLower().Replace(" ", "-"), + slug: SlugHelper.Generate(providerName), location: location, subscriptionTier: _faker.Random.Enum(), description: _faker.Lorem.Sentence(), diff --git a/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs index fcc3d4088..8f23024a8 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs @@ -52,7 +52,7 @@ private SearchableProvider CreateTestProvider( var actualProviderId = providerId ?? UuidGenerator.NewId(); var actualName = name ?? _faker.Company.CompanyName(); - var actualSlug = actualName.ToLower().Replace(" ", "-"); + var actualSlug = SlugHelper.Generate(actualName); var actualSubscriptionTier = tier ?? _faker.Random.Enum(); return SearchableProvider.Create( diff --git a/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs index b0adeeac0..e869e2357 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs @@ -22,5 +22,6 @@ public sealed record ProviderProfileUpdatedIntegrationEvent( string? UpdatedBy = null, string? PreviousName = null, string? NewEmail = null, - string? NewPhoneNumber = null + string? NewPhoneNumber = null, + string? Slug = null ) : IntegrationEvent(Source); diff --git a/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs index d7e08d2f9..5af7e8c0e 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs @@ -23,5 +23,6 @@ public sealed record ProviderRegisteredIntegrationEvent( string? PhoneNumber = null, string? City = null, string? State = null, - DateTime? RegisteredAt = null + DateTime? RegisteredAt = null, + string? Slug = null ) : IntegrationEvent(Source); diff --git a/src/Shared/Utilities/SlugHelper.cs b/src/Shared/Utilities/SlugHelper.cs index 8cab9d559..7833dad3a 100644 --- a/src/Shared/Utilities/SlugHelper.cs +++ b/src/Shared/Utilities/SlugHelper.cs @@ -47,6 +47,15 @@ public static string Generate(string text) return slug.Trim('-'); } + /// + /// Gera um slug a partir de um texto com sufixo diferenciador. + /// + /// Texto original + /// Sufixo a ser anexado (ex.: primeiros 8 chars do ID) + /// Slug formatado com sufixo único + public static string GenerateWithSuffix(string text, string suffix) + => $"{Generate(text)}-{suffix}"; + private static string RemoveDiacritics(string text) { var normalizedString = text.Normalize(NormalizationForm.FormD); diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs index d6cfca5cf..e273d8b13 100644 --- a/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs @@ -3,6 +3,7 @@ namespace MeAjudaAi.Shared.Tests.Unit.Utilities; +[Trait("Category", "Unit")] public class SlugHelperTests { [Theory] From 9cf226af12be749298e6aa5dee3aee15b0820850 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Tue, 17 Mar 2026 18:05:59 -0300 Subject: [PATCH 03/24] feat: Implement public provider retrieval by ID or slug, including new API endpoints, queries, and tests. --- .../API/Endpoints/ProvidersModuleEndpoints.cs | 2 +- ...=> GetPublicProviderByIdOrSlugEndpoint.cs} | 16 +- .../Providers/Application/Extensions.cs | 2 +- ...etPublicProviderByIdOrSlugQueryHandler.cs} | 13 +- .../GetPublicProviderByIdOrSlugQuery.cs | 26 +++ .../Queries/GetPublicProviderByIdQuery.cs | 20 --- .../Providers/Domain/Entities/Provider.cs | 3 +- .../TestInfrastructureExtensions.cs | 2 +- .../GetPublicProviderByIdIntegrationTests.cs | 12 +- .../GetPublicProviderByIdQueryHandlerTests.cs | 155 ------------------ .../GetPublicProviderByIdQueryHandlerTests.cs | 12 +- .../Unit/Domain/Entities/ProviderTests.cs | 2 +- .../ProviderConfigurationTests.cs | 3 +- .../Domain/Entities/SearchableProvider.cs | 14 +- .../Infrastructure/Extensions.cs | 31 ++-- .../SearchProvidersDbContextModelSnapshot.cs | 10 +- ...54_AddSlugToSearchableProvider.Designer.cs | 136 +++++++++++++++ ...60317151054_AddSlugToSearchableProvider.cs | 53 ++++++ .../SearchProvidersIntegrationTestBase.cs | 2 +- .../Utilities/Constants/ApiEndpoints.cs | 2 +- src/Shared/Utilities/SlugHelper.cs | 5 +- 21 files changed, 293 insertions(+), 228 deletions(-) rename src/Modules/Providers/API/Endpoints/Public/{GetPublicProviderByIdEndpoint.cs => GetPublicProviderByIdOrSlugEndpoint.cs} (80%) rename src/Modules/Providers/Application/Handlers/Queries/{GetPublicProviderByIdQueryHandler.cs => GetPublicProviderByIdOrSlugQueryHandler.cs} (85%) create mode 100644 src/Modules/Providers/Application/Queries/GetPublicProviderByIdOrSlugQuery.cs delete mode 100644 src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs delete mode 100644 src/Modules/Providers/Tests/Unit/Application/Handlers/GetPublicProviderByIdQueryHandlerTests.cs create mode 100644 src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.Designer.cs create mode 100644 src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.cs diff --git a/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs b/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs index 5c3b4d2a9..a10a5d28a 100644 --- a/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs +++ b/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs @@ -54,7 +54,7 @@ public static void MapProvidersEndpoints(this WebApplication app) endpoints.MapEndpoint() .MapEndpoint() .MapEndpoint() - .MapEndpoint() // Novo endpoint público + .MapEndpoint() // Endpoint público por ID ou slug .MapEndpoint() // Endpoint para usuário autenticado virar prestador .MapEndpoint() .MapEndpoint() diff --git a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs similarity index 80% rename from src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs rename to src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs index ef073e8a4..86031a7f0 100644 --- a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs +++ b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs @@ -14,17 +14,17 @@ namespace MeAjudaAi.Modules.Providers.API.Endpoints.Public; /// -/// Endpoint público para consulta de detalhes básicos do prestador. +/// Endpoint público para consulta de detalhes básicos do prestador por ID ou slug. /// -public class GetPublicProviderByIdEndpoint : BaseEndpoint, IEndpoint +public class GetPublicProviderByIdOrSlugEndpoint : BaseEndpoint, IEndpoint { public static void Map(IEndpointRouteBuilder app) - => app.MapGet(ApiEndpoints.Providers.GetPublicById, GetPublicProviderAsync) - .WithName("GetPublicProviderById") + => app.MapGet(ApiEndpoints.Providers.GetPublicByIdOrSlug, GetPublicProviderAsync) + .WithName("GetPublicProviderByIdOrSlug") .WithSummary("Consultar perfil público do prestador") .WithDescription(""" Recupera dados públicos e seguros de um prestador para exibição no site. - Não requer autenticação. + Não requer autenticação. Aceita ID (GUID) ou slug amigável (ex.: "joao-silva-a1b2c3d4"). **Dados Retornados:** - Informações básicas (Nome, Fantasia, Descrição) @@ -43,14 +43,14 @@ Não requer autenticação. .Produces(StatusCodes.Status404NotFound); private static async Task GetPublicProviderAsync( - Guid id, + string idOrSlug, IQueryDispatcher queryDispatcher, HttpContext httpContext, CancellationToken cancellationToken) { var isAuthenticated = httpContext.User.Identity?.IsAuthenticated ?? false; - var query = new GetPublicProviderByIdQuery(id, isAuthenticated); - var result = await queryDispatcher.QueryAsync>( + var query = new GetPublicProviderByIdOrSlugQuery(idOrSlug, isAuthenticated); + var result = await queryDispatcher.QueryAsync>( query, cancellationToken); return Handle(result); diff --git a/src/Modules/Providers/Application/Extensions.cs b/src/Modules/Providers/Application/Extensions.cs index 770010b80..6feb06fad 100644 --- a/src/Modules/Providers/Application/Extensions.cs +++ b/src/Modules/Providers/Application/Extensions.cs @@ -29,7 +29,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddScoped>>, GetProvidersByStateQueryHandler>(); services.AddScoped>>, GetProvidersByTypeQueryHandler>(); services.AddScoped>>, GetProvidersByVerificationStatusQueryHandler>(); - services.AddScoped>, GetPublicProviderByIdQueryHandler>(); + services.AddScoped>, GetPublicProviderByIdOrSlugQueryHandler>(); // Command Handlers - registro manual para garantir disponibilidade services.AddScoped>, CreateProviderCommandHandler>(); diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs similarity index 85% rename from src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs rename to src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs index 9b7414f37..5c25cfae0 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs @@ -15,24 +15,27 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Queries; /// -/// Handler responsável por processar a query de busca de prestador público. +/// Handler responsável por processar a query de busca de prestador público por ID ou slug. /// -public sealed class GetPublicProviderByIdQueryHandler : IQueryHandler> +public sealed class GetPublicProviderByIdOrSlugQueryHandler : IQueryHandler> { private readonly IProviderRepository _providerRepository; private readonly IFeatureManager _featureManager; - public GetPublicProviderByIdQueryHandler(IProviderRepository providerRepository, IFeatureManager featureManager) + public GetPublicProviderByIdOrSlugQueryHandler(IProviderRepository providerRepository, IFeatureManager featureManager) { _providerRepository = providerRepository; _featureManager = featureManager; } public async Task> HandleAsync( - GetPublicProviderByIdQuery query, + GetPublicProviderByIdOrSlugQuery query, CancellationToken cancellationToken) { - var provider = await _providerRepository.GetByIdAsync(query.Id, cancellationToken); + // Resolve por ID (GUID) ou slug — mesma lógica do padrão de filmes + var provider = Guid.TryParse(query.IdOrSlug, out var id) + ? await _providerRepository.GetByIdAsync(new ProviderId(id), cancellationToken) + : await _providerRepository.GetBySlugAsync(query.IdOrSlug, cancellationToken); if (provider is null) { diff --git a/src/Modules/Providers/Application/Queries/GetPublicProviderByIdOrSlugQuery.cs b/src/Modules/Providers/Application/Queries/GetPublicProviderByIdOrSlugQuery.cs new file mode 100644 index 000000000..a97b42a2e --- /dev/null +++ b/src/Modules/Providers/Application/Queries/GetPublicProviderByIdOrSlugQuery.cs @@ -0,0 +1,26 @@ +using MeAjudaAi.Contracts; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Modules.Providers.Application.DTOs; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.Providers.Application.Queries; + +/// +/// Query para buscar dados públicos de um prestador por ID (GUID) ou slug. +/// Acessível sem autenticação. +/// +public sealed record GetPublicProviderByIdOrSlugQuery(string IdOrSlug, bool IsAuthenticated = false) : Query>, ICacheableQuery +{ + public string GetCacheKey() => $"provider:public:{IdOrSlug}:{(IsAuthenticated ? "auth" : "anon")}"; + + // Cache de 10 minutos para dados públicos (bom para SEO e performance) + public TimeSpan GetCacheExpiration() => TimeSpan.FromMinutes(10); + + public IReadOnlyCollection? GetCacheTags() + { + if (Guid.TryParse(IdOrSlug, out var id)) + return ["providers", $"provider:{id}"]; + + return ["providers", $"provider-slug:{IdOrSlug}"]; + } +} diff --git a/src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs b/src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs deleted file mode 100644 index 47ee4901f..000000000 --- a/src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs +++ /dev/null @@ -1,20 +0,0 @@ -using MeAjudaAi.Contracts; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Modules.Providers.Application.DTOs; -using MeAjudaAi.Shared.Queries; - -namespace MeAjudaAi.Modules.Providers.Application.Queries; - -/// -/// Query para buscar dados públicos de um prestador pelo ID. -/// Acessível sem autenticação. -/// -public sealed record GetPublicProviderByIdQuery(Guid Id, bool IsAuthenticated = false) : Query>, ICacheableQuery -{ - public string GetCacheKey() => $"provider:public:{Id}:{(IsAuthenticated ? "auth" : "anon")}"; - - // Cache de 10 minutos para dados públicos (bom para SEO e performance) - public TimeSpan GetCacheExpiration() => TimeSpan.FromMinutes(10); - - public IReadOnlyCollection? GetCacheTags() => ["providers", $"provider:{Id}"]; -} diff --git a/src/Modules/Providers/Domain/Entities/Provider.cs b/src/Modules/Providers/Domain/Entities/Provider.cs index 211489924..aaa707819 100644 --- a/src/Modules/Providers/Domain/Entities/Provider.cs +++ b/src/Modules/Providers/Domain/Entities/Provider.cs @@ -213,8 +213,7 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? { updatedFields.Add("Name"); Name = newName; - Slug = SlugHelper.GenerateWithSuffix(Name, Id.Value.ToString("N")[..8]); - updatedFields.Add("Slug"); + // Slug é imutável após a criação — não regenerar para preservar URLs públicas } if (!BusinessProfile.ContactInfo.Email.Equals(businessProfile.ContactInfo.Email, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs b/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs index 8b56b805d..dd973b38a 100644 --- a/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs +++ b/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs @@ -112,7 +112,7 @@ public static IServiceCollection AddProvidersTestInfrastructure( services.AddScoped(); // Registrar handlers de teste explicitamente - services.AddScoped>, GetPublicProviderByIdQueryHandler>(); + services.AddScoped>, GetPublicProviderByIdOrSlugQueryHandler>(); services.AddScoped>, GetProviderByUserIdQueryHandler>(); services.AddScoped>, UpdateProviderProfileCommandHandler>(); diff --git a/src/Modules/Providers/Tests/Integration/GetPublicProviderByIdIntegrationTests.cs b/src/Modules/Providers/Tests/Integration/GetPublicProviderByIdIntegrationTests.cs index b581d71da..514e5f134 100644 --- a/src/Modules/Providers/Tests/Integration/GetPublicProviderByIdIntegrationTests.cs +++ b/src/Modules/Providers/Tests/Integration/GetPublicProviderByIdIntegrationTests.cs @@ -34,10 +34,10 @@ public async Task GetPublicProviderById_ActiveProvider_ShouldReturnDto() await DbContext.SaveChangesAsync(); var dispatcher = GetService(); - var query = new GetPublicProviderByIdQuery(provider.Id); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); // Act - var result = await dispatcher.QueryAsync>(query, CancellationToken.None); + var result = await dispatcher.QueryAsync>(query, CancellationToken.None); // Assert result.IsSuccess.Should().BeTrue(); @@ -68,10 +68,10 @@ public async Task GetPublicProviderById_InactiveProvider_ShouldReturnNotFound() await DbContext.SaveChangesAsync(); var dispatcher = GetService(); - var query = new GetPublicProviderByIdQuery(provider.Id); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); // Act - var result = await dispatcher.QueryAsync>(query, CancellationToken.None); + var result = await dispatcher.QueryAsync>(query, CancellationToken.None); // Assert result.IsSuccess.Should().BeFalse(); @@ -84,10 +84,10 @@ public async Task GetPublicProviderById_NonExistentProvider_ShouldReturnNotFound // Arrange await CleanupDatabase(); var dispatcher = GetService(); - var query = new GetPublicProviderByIdQuery(Guid.NewGuid()); + var query = new GetPublicProviderByIdOrSlugQuery(Guid.NewGuid().ToString()); // Act - var result = await dispatcher.QueryAsync>(query, CancellationToken.None); + var result = await dispatcher.QueryAsync>(query, CancellationToken.None); // Assert result.IsSuccess.Should().BeFalse(); diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/GetPublicProviderByIdQueryHandlerTests.cs deleted file mode 100644 index 9620d5530..000000000 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/GetPublicProviderByIdQueryHandlerTests.cs +++ /dev/null @@ -1,155 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Modules.Providers.Application.Handlers.Queries; -using MeAjudaAi.Modules.Providers.Application.Queries; -using MeAjudaAi.Modules.Providers.Domain.Entities; -using MeAjudaAi.Modules.Providers.Domain.Repositories; -using Moq; -using MeAjudaAi.Modules.Providers.Domain.Enums; -using MeAjudaAi.Modules.Providers.Domain.ValueObjects; - -using Microsoft.FeatureManagement; - -namespace MeAjudaAi.Modules.Providers.Tests.Unit.Application.Handlers; - -public class GetPublicProviderByIdQueryHandlerTests -{ - private readonly Mock _providerRepositoryMock; - private readonly Mock _featureManagerMock; - private readonly GetPublicProviderByIdQueryHandler _handler; - - public GetPublicProviderByIdQueryHandlerTests() - { - _providerRepositoryMock = new Mock(); - _featureManagerMock = new Mock(); - _handler = new GetPublicProviderByIdQueryHandler(_providerRepositoryMock.Object, _featureManagerMock.Object); - } - - [Fact] - public async Task Handle_Should_Return_Provider_When_Found_And_Active() - { - // Arrange - var providerId = Guid.NewGuid(); - var provider = CreateTestProvider(providerId); - - var statusProperty = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProperty.Should().NotBeNull("Status property should be available on Provider for tests"); - statusProperty!.SetValue(provider, EProviderStatus.Active); - - _providerRepositoryMock.Setup(x => x.GetByIdAsync(It.Is(id => id.Value == providerId), It.IsAny())) - .ReturnsAsync(provider); - - var query = new GetPublicProviderByIdQuery(providerId); - - // Act - var result = await _handler.HandleAsync(query, CancellationToken.None); - - // Assert - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBeNull(); - result.Value!.Id.Should().Be(providerId); - result.Value.Name.Should().Be("Test Provider"); - result.Value.FantasyName.Should().Be("Fantasy Name"); - - // Assert non-restricted fields are populated - result.Value.Services.Should().NotBeNull(); - result.Value.PhoneNumbers.Should().NotBeNull(); - } - - [Fact] - public async Task Handle_Should_Return_NotFound_When_Provider_Does_Not_Exist() - { - // Arrange - var providerId = Guid.NewGuid(); - _providerRepositoryMock.Setup(x => x.GetByIdAsync(It.Is(id => id.Value == providerId), It.IsAny())) - .ReturnsAsync((Provider?)null); - - var query = new GetPublicProviderByIdQuery(providerId); - - // Act - var result = await _handler.HandleAsync(query, CancellationToken.None); - - // Assert - result.IsSuccess.Should().BeFalse(); - result.Error.StatusCode.Should().Be(404); - } - - [Fact] - public async Task Handle_Should_Return_NotFound_When_Provider_Is_Not_Active() - { - // Arrange - var providerId = Guid.NewGuid(); - var provider = CreateTestProvider(providerId); - - // Set status to Suspended using reflection to ensure test isolation from default state - var statusProperty = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProperty.Should().NotBeNull("Status property should be available on Provider for tests"); - statusProperty!.SetValue(provider, EProviderStatus.Suspended); - - _providerRepositoryMock.Setup(x => x.GetByIdAsync(It.Is(id => id.Value == providerId), It.IsAny())) - .ReturnsAsync(provider); - - var query = new GetPublicProviderByIdQuery(providerId); - - // Act - var result = await _handler.HandleAsync(query, CancellationToken.None); - - // Assert - result.IsSuccess.Should().BeFalse(); - result.Error.StatusCode.Should().Be(404); // Public endpoint hides inactive providers as Not Found - } - - [Fact] - public async Task Handle_Should_Return_Restricted_Provider_When_Privacy_Flag_Is_Enabled() - { - // Arrange - var providerId = Guid.NewGuid(); - var provider = CreateTestProvider(providerId); - - var statusProperty = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProperty.Should().NotBeNull("Provider.Status property must exist"); - statusProperty!.SetValue(provider, EProviderStatus.Active); - - _providerRepositoryMock.Setup(x => x.GetByIdAsync(It.Is(id => id.Value == providerId), It.IsAny())) - .ReturnsAsync(provider); - - // Enable privacy flag - _featureManagerMock.Setup(x => x.IsEnabledAsync(MeAjudaAi.Shared.Utilities.Constants.FeatureFlags.PublicProfilePrivacy)) - .ReturnsAsync(true); - - var query = new GetPublicProviderByIdQuery(providerId); - - // Act - var result = await _handler.HandleAsync(query, CancellationToken.None); - - // Assert - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBeNull(); - - // Verify restricted fields are empty - result.Value!.Services.Should().BeEmpty(); - result.Value.PhoneNumbers.Should().BeEmpty(); - result.Value.Email.Should().BeNull(); - - // Verify non-restricted fields are still present - result.Value.Name.Should().Be("Test Provider"); - result.Value.FantasyName.Should().Be("Fantasy Name"); - } - - private static Provider CreateTestProvider(Guid? providerId = null, Guid? userId = null) - { - return new Provider( - new ProviderId(providerId ?? Guid.NewGuid()), - userId ?? Guid.NewGuid(), - "Test Provider", - EProviderType.Individual, - new BusinessProfile( - "Legal Name", - new ContactInfo("test@example.com", "123456789"), - new Address("Street", "123", "Neighborhood", "City", "State", "12345678"), - "Fantasy Name", - "Description" - ) - ); - } -} diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs index 0a4c0c46c..226eb88f8 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs @@ -18,13 +18,13 @@ public class GetPublicProviderByIdQueryHandlerTests { private readonly Mock _providerRepositoryMock; private readonly Mock _featureManagerMock; - private readonly GetPublicProviderByIdQueryHandler _handler; + private readonly GetPublicProviderByIdOrSlugQueryHandler _handler; public GetPublicProviderByIdQueryHandlerTests() { _providerRepositoryMock = new Mock(); _featureManagerMock = new Mock(); - _handler = new GetPublicProviderByIdQueryHandler(_providerRepositoryMock.Object, _featureManagerMock.Object); + _handler = new GetPublicProviderByIdOrSlugQueryHandler(_providerRepositoryMock.Object, _featureManagerMock.Object); } [Fact] @@ -45,7 +45,7 @@ public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificati .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) .ReturnsAsync(provider); - var query = new GetPublicProviderByIdQuery(provider.Id); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); // Act var result = await _handler.HandleAsync(query, CancellationToken.None); @@ -70,7 +70,7 @@ public async Task HandleAsync_WhenProviderIsNotActive_ShouldReturnNotFound() .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) .ReturnsAsync(provider); - var query = new GetPublicProviderByIdQuery(provider.Id); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); // Act var result = await _handler.HandleAsync(query, CancellationToken.None); @@ -90,7 +90,7 @@ public async Task HandleAsync_WhenProviderNotFound_ShouldReturnNotFound() .Setup(x => x.GetByIdAsync(providerId, It.IsAny())) .ReturnsAsync((Provider?)null); - var query = new GetPublicProviderByIdQuery(providerId); + var query = new GetPublicProviderByIdOrSlugQuery(providerId.ToString()); // Act var result = await _handler.HandleAsync(query, CancellationToken.None); @@ -125,7 +125,7 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) .ReturnsAsync(true); - var query = new GetPublicProviderByIdQuery(provider.Id); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); // Act var result = await _handler.HandleAsync(query, CancellationToken.None); diff --git a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs index 273729cc9..cbab7b39e 100644 --- a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs +++ b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs @@ -168,7 +168,7 @@ public void UpdateProfile_WithValidParameters_ShouldUpdateProvider() var profileUpdatedEvent = (ProviderProfileUpdatedDomainEvent)updateEvent; profileUpdatedEvent.Name.Should().Be(newName); - profileUpdatedEvent.Slug.Should().StartWith("updated-provider-name-"); + profileUpdatedEvent.Slug.Should().Be(provider.Slug); profileUpdatedEvent.UpdatedBy.Should().Be(updatedBy); } diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs index bdac1955c..a1c7cb2fe 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs @@ -832,7 +832,8 @@ public void Configure_ShouldHaveSixIndexes() // 4. Status // 5. VerificationStatus // 6. IsDeleted - _entityType.GetIndexes().Should().HaveCount(6); + // 7. Slug (unique) + _entityType.GetIndexes().Should().HaveCount(7); } #endregion diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 614f719ce..a5d4dea85 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -126,7 +126,7 @@ public static SearchableProvider Create( location, subscriptionTier) { - Slug = slug.Trim().ToLowerInvariant(), + Slug = NormalizeSlug(slug), Description = description?.Trim(), City = city?.Trim(), State = state?.Trim() @@ -135,6 +135,14 @@ public static SearchableProvider Create( return searchableProvider; } + private static string NormalizeSlug(string slug) + { + var normalized = slug.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(normalized)) + throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); + return normalized; + } + /// /// Reconstitui uma entidade existente do banco de dados. /// Usado internamente pela camada de infraestrutura (Dapper queries) para reconstruir @@ -166,7 +174,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = slug?.Trim().ToLowerInvariant() ?? string.Empty, + Slug = NormalizeSlug(slug ?? string.Empty), Description = description, City = city, State = state, @@ -195,7 +203,7 @@ public void UpdateBasicInfo(string name, string slug, string? description, strin } Name = name.Trim(); - Slug = slug.Trim().ToLowerInvariant(); + Slug = NormalizeSlug(slug); Description = description?.Trim(); City = city?.Trim(); State = state?.Trim(); diff --git a/src/Modules/SearchProviders/Infrastructure/Extensions.cs b/src/Modules/SearchProviders/Infrastructure/Extensions.cs index 01edf0f8b..18f1d80a3 100644 --- a/src/Modules/SearchProviders/Infrastructure/Extensions.cs +++ b/src/Modules/SearchProviders/Infrastructure/Extensions.cs @@ -41,27 +41,32 @@ public static IServiceCollection AddSearchProvidersInfrastructure( // Em ambiente de teste, permitir inicialização sem connection string // (útil para testes unitários que não acessam o banco) // Só fazemos bypass se estivermos explicitamente em Desenvolvimento e NÃO for um live environment - var isTesting = environment.IsDevelopment() && MeAjudaAi.Shared.Utilities.EnvironmentHelpers.IsSecurityBypassEnvironment(environment); + var isTesting = MeAjudaAi.Shared.Utilities.EnvironmentHelpers.IsSecurityBypassEnvironment(environment); - if (string.IsNullOrEmpty(connectionString) && !isTesting) + if (string.IsNullOrEmpty(connectionString)) { - throw new InvalidOperationException( - "Database connection string not found. Tried: 'DefaultConnection', 'Search', 'meajudaai-db'. " + - "Please configure one of these connection strings in appsettings.json or environment variables."); + if (isTesting) + { +#pragma warning disable S2068 // "password" detected here, make sure this is not a hard-coded credential + connectionString = MeAjudaAi.Shared.Database.DatabaseConstants.DefaultTestConnectionString; +#pragma warning restore S2068 + } + else + { + var env1 = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var env2 = Environment.GetEnvironmentVariable("INTEGRATION_TESTS"); + var env3 = environment?.EnvironmentName; + throw new InvalidOperationException( + $"DEBUG: isTesting={isTesting}, ASPNETCORE={env1}, INTEGRATION_TESTS={env2}, EnvName={env3} " + + "Database connection string not found. Tried: 'DefaultConnection', 'Search', 'meajudaai-db'."); + } } // Sempre registrar DbContext (mesmo que connection string seja vazia em testes unitários) // Em E2E tests, a connection string será fornecida via configuração services.AddDbContext((serviceProvider, options) => { - // Se não houver connection string, usar uma default para testes unitários -#pragma warning disable S2068 // "password" detected here, make sure this is not a hard-coded credential - var connStr = !string.IsNullOrEmpty(connectionString) - ? connectionString - : MeAjudaAi.Shared.Database.DatabaseConstants.DefaultTestConnectionString; -#pragma warning restore S2068 - - options.UseNpgsql(connStr, npgsqlOptions => + options.UseNpgsql(connectionString, npgsqlOptions => { npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "search_providers"); npgsqlOptions.UseNetTopologySuite(); // Habilitar suporte PostGIS/geoespacial diff --git a/src/Modules/SearchProviders/Infrastructure/Migrations/SearchProvidersDbContextModelSnapshot.cs b/src/Modules/SearchProviders/Infrastructure/Migrations/SearchProvidersDbContextModelSnapshot.cs index 2ae7a3667..8f07f0724 100644 --- a/src/Modules/SearchProviders/Infrastructure/Migrations/SearchProvidersDbContextModelSnapshot.cs +++ b/src/Modules/SearchProviders/Infrastructure/Migrations/SearchProvidersDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -19,7 +19,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("search_providers") - .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("ProductVersion", "10.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); @@ -76,6 +76,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid[]") .HasColumnName("service_ids"); + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("slug"); + b.Property("State") .HasMaxLength(2) .HasColumnType("character varying(2)") diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.Designer.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.Designer.cs new file mode 100644 index 000000000..dbdf5e786 --- /dev/null +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(SearchProvidersDbContext))] + [Migration("20260317151054_AddSlugToSearchableProvider")] + partial class AddSlugToSearchableProvider + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("search_providers") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.SearchProviders.Domain.Entities.SearchableProvider", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("numeric(3,2)") + .HasColumnName("average_rating"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("city"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("Location") + .IsRequired() + .HasColumnType("geography(Point, 4326)") + .HasColumnName("location"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.PrimitiveCollection("ServiceIds") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("service_ids"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("slug"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("state"); + + b.Property("SubscriptionTier") + .HasColumnType("integer") + .HasColumnName("subscription_tier"); + + b.Property("TotalReviews") + .HasColumnType("integer") + .HasColumnName("total_reviews"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_searchable_providers"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_searchable_providers_is_active"); + + b.HasIndex("Location") + .HasDatabaseName("ix_searchable_providers_location"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist"); + + b.HasIndex("ProviderId") + .IsUnique() + .HasDatabaseName("ix_searchable_providers_provider_id"); + + b.HasIndex("ServiceIds") + .HasDatabaseName("ix_searchable_providers_service_ids"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("ServiceIds"), "gin"); + + b.HasIndex("SubscriptionTier") + .HasDatabaseName("ix_searchable_providers_subscription_tier"); + + b.HasIndex("IsActive", "SubscriptionTier", "AverageRating") + .HasDatabaseName("ix_searchable_providers_search_ranking"); + + b.ToTable("searchable_providers", "search_providers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.cs new file mode 100644 index 000000000..1fd9ddda0 --- /dev/null +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSlugToSearchableProvider : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1. Adicionar a coluna como NULLABLE + migrationBuilder.AddColumn( + name: "slug", + schema: "search_providers", + table: "searchable_providers", + type: "character varying(120)", + maxLength: 120, + nullable: true); + + // 2. Executar um backfill UPDATE + // Cria um slug rudimentar a partir do nome e anexa os primeiros 8 caracteres do ProviderId (no formato "N" / sem hífens) + migrationBuilder.Sql(@" + UPDATE search_providers.searchable_providers + SET slug = LOWER(REPLACE(name, ' ', '-')) || '-' || SUBSTRING(REPLACE(provider_id::text, '-', ''), 1, 8) + WHERE slug IS NULL OR slug = ''; + "); + + // 3. Alterar a coluna para NOT NULL após o preenchimento dos dados + migrationBuilder.AlterColumn( + name: "slug", + schema: "search_providers", + table: "searchable_providers", + type: "character varying(120)", + maxLength: 120, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(120)", + oldMaxLength: 120, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "slug", + schema: "search_providers", + table: "searchable_providers"); + } + } +} diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 52be74493..884bcc404 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -165,7 +165,7 @@ protected SearchableProvider CreateTestSearchableProvider( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: SlugHelper.Generate(name), + slug: SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), location: location, subscriptionTier: tier, description: description, diff --git a/src/Shared/Utilities/Constants/ApiEndpoints.cs b/src/Shared/Utilities/Constants/ApiEndpoints.cs index a7de4845c..b0c31a189 100644 --- a/src/Shared/Utilities/Constants/ApiEndpoints.cs +++ b/src/Shared/Utilities/Constants/ApiEndpoints.cs @@ -49,7 +49,7 @@ public static class Providers public const string AddDocument = "/{id:guid}/documents"; // POST AddDocumentEndpoint public const string RemoveDocument = "/{id:guid}/documents/{documentType}"; // DELETE RemoveDocumentEndpoint public const string RequireBasicInfoCorrection = "/{id:guid}/require-basic-info-correction"; // POST RequireBasicInfoCorrectionEndpoint - public const string GetPublicById = "/{id:guid}/public"; // GET GetPublicProviderByIdEndpoint + public const string GetPublicByIdOrSlug = "/{idOrSlug}"; // GET GetPublicProviderByIdEndpoint (aceita GUID ou slug) } /// diff --git a/src/Shared/Utilities/SlugHelper.cs b/src/Shared/Utilities/SlugHelper.cs index 7833dad3a..0263a003e 100644 --- a/src/Shared/Utilities/SlugHelper.cs +++ b/src/Shared/Utilities/SlugHelper.cs @@ -54,7 +54,10 @@ public static string Generate(string text) /// Sufixo a ser anexado (ex.: primeiros 8 chars do ID) /// Slug formatado com sufixo único public static string GenerateWithSuffix(string text, string suffix) - => $"{Generate(text)}-{suffix}"; + { + var baseSlug = Generate(text); + return string.IsNullOrEmpty(baseSlug) ? suffix : $"{baseSlug}-{suffix}"; + } private static string RemoveDiacritics(string text) { From 848fda86f6df6860f31f888fd704fb9d8b128aa0 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Tue, 17 Mar 2026 19:51:01 -0300 Subject: [PATCH 04/24] feat: Implement public provider profiles accessible by ID or slug, supported by a new searchable provider entity and slug utility. --- .../GetPublicProviderByIdOrSlugEndpoint.cs | 2 +- .../Configurations/ProviderConfiguration.cs | 1 - .../GetPublicProviderByIdQueryHandlerTests.cs | 28 +++++++++++++++++++ .../ProviderConfigurationTests.cs | 6 ++-- .../Domain/Entities/SearchableProvider.cs | 15 +++------- .../SearchProvidersIntegrationTestBase.cs | 2 +- .../Utilities/Constants/ApiEndpoints.cs | 2 +- src/Shared/Utilities/SlugHelper.cs | 9 ++++-- .../app/(main)/buscar/page.tsx | 8 +++--- 9 files changed, 49 insertions(+), 24 deletions(-) diff --git a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs index 86031a7f0..487107c0e 100644 --- a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs +++ b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs @@ -30,7 +30,7 @@ Não requer autenticação. Aceita ID (GUID) ou slug amigável (ex.: "joao-silva - Informações básicas (Nome, Fantasia, Descrição) - Localização aproximada (Cidade/Estado) - Avaliação média e contagem de reviews - - Lista de serviços oferecidos + - Lista de serviços oferecidos (Nota: esta lista será vazia se a configuração PublicProfilePrivacy do provedor estiver ativa e o solicitante for anônimo) **Dados Ocultados (Privacidade):** - Documentos (CPF/CNPJ) diff --git a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs index 2d5c2450a..d506142e9 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs @@ -248,7 +248,6 @@ public void Configure(EntityTypeBuilder builder) .HasDatabaseName("ix_providers_user_id"); builder.HasIndex(p => p.Slug) - .IsUnique() .HasDatabaseName("ix_providers_slug"); builder.HasIndex(p => p.Name) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs index 226eb88f8..01946cef6 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs @@ -58,6 +58,34 @@ public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificati result.Value.VerificationStatus.Should().Be(EVerificationStatus.Verified); } + [Fact] + public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() + { + // Arrange + var provider = ProviderBuilder.Create() + .WithType(EProviderType.Individual) + .WithVerificationStatus(EVerificationStatus.Verified) + .Build(); + + // Bypass domain transitions to set Active status directly for test + var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); + statusProp!.SetValue(provider, EProviderStatus.Active); + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(provider.Slug, It.IsAny())) + .ReturnsAsync(provider); + + var query = new GetPublicProviderByIdOrSlugQuery(provider.Slug); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(provider.Id); + } + [Fact] public async Task HandleAsync_WhenProviderIsNotActive_ShouldReturnNotFound() { diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs index a1c7cb2fe..b475c2ecb 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs @@ -823,16 +823,16 @@ public void Configure_ShouldHaveIndexOnIsDeleted() } [Fact] - public void Configure_ShouldHaveSixIndexes() + public void Configure_ShouldHaveSevenIndexes() { - // Assert - 6 indexes on Provider entity itself: + // Assert - 7 indexes on Provider entity itself: // 1. UserId (unique) // 2. Name // 3. Type // 4. Status // 5. VerificationStatus // 6. IsDeleted - // 7. Slug (unique) + // 7. Slug (no longer unique) _entityType.GetIndexes().Should().HaveCount(7); } diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index a5d4dea85..26408b00b 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -2,6 +2,7 @@ using MeAjudaAi.Modules.SearchProviders.Domain.ValueObjects; using MeAjudaAi.Shared.Domain; using MeAjudaAi.Shared.Geolocation; +using MeAjudaAi.Shared.Utilities; namespace MeAjudaAi.Modules.SearchProviders.Domain.Entities; @@ -126,7 +127,7 @@ public static SearchableProvider Create( location, subscriptionTier) { - Slug = NormalizeSlug(slug), + Slug = SlugHelper.Generate(slug), Description = description?.Trim(), City = city?.Trim(), State = state?.Trim() @@ -135,14 +136,6 @@ public static SearchableProvider Create( return searchableProvider; } - private static string NormalizeSlug(string slug) - { - var normalized = slug.Trim().ToLowerInvariant(); - if (string.IsNullOrEmpty(normalized)) - throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); - return normalized; - } - /// /// Reconstitui uma entidade existente do banco de dados. /// Usado internamente pela camada de infraestrutura (Dapper queries) para reconstruir @@ -174,7 +167,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = NormalizeSlug(slug ?? string.Empty), + Slug = SlugHelper.Generate(slug ?? string.Empty), Description = description, City = city, State = state, @@ -203,7 +196,7 @@ public void UpdateBasicInfo(string name, string slug, string? description, strin } Name = name.Trim(); - Slug = NormalizeSlug(slug); + Slug = SlugHelper.Generate(slug); Description = description?.Trim(); City = city?.Trim(); State = state?.Trim(); diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 884bcc404..217116a08 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -193,7 +193,7 @@ protected SearchableProvider CreateTestSearchableProviderWithProviderId( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: SlugHelper.Generate(name), + slug: SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), location: location, subscriptionTier: tier, description: description, diff --git a/src/Shared/Utilities/Constants/ApiEndpoints.cs b/src/Shared/Utilities/Constants/ApiEndpoints.cs index b0c31a189..b44dbfede 100644 --- a/src/Shared/Utilities/Constants/ApiEndpoints.cs +++ b/src/Shared/Utilities/Constants/ApiEndpoints.cs @@ -49,7 +49,7 @@ public static class Providers public const string AddDocument = "/{id:guid}/documents"; // POST AddDocumentEndpoint public const string RemoveDocument = "/{id:guid}/documents/{documentType}"; // DELETE RemoveDocumentEndpoint public const string RequireBasicInfoCorrection = "/{id:guid}/require-basic-info-correction"; // POST RequireBasicInfoCorrectionEndpoint - public const string GetPublicByIdOrSlug = "/{idOrSlug}"; // GET GetPublicProviderByIdEndpoint (aceita GUID ou slug) + public const string GetPublicByIdOrSlug = "/public/{idOrSlug}"; // GET GetPublicProviderByIdEndpoint } /// diff --git a/src/Shared/Utilities/SlugHelper.cs b/src/Shared/Utilities/SlugHelper.cs index 0263a003e..c4122bdc5 100644 --- a/src/Shared/Utilities/SlugHelper.cs +++ b/src/Shared/Utilities/SlugHelper.cs @@ -55,8 +55,13 @@ public static string Generate(string text) /// Slug formatado com sufixo único public static string GenerateWithSuffix(string text, string suffix) { - var baseSlug = Generate(text); - return string.IsNullOrEmpty(baseSlug) ? suffix : $"{baseSlug}-{suffix}"; + var baseSlug = Generate(text ?? string.Empty); + var suffixSlug = Generate((suffix ?? string.Empty).Trim()); + + if (string.IsNullOrEmpty(suffixSlug)) return baseSlug; + if (string.IsNullOrEmpty(baseSlug)) return suffixSlug; + + return $"{baseSlug}-{suffixSlug}"; } private static string RemoveDiacritics(string text) diff --git a/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx b/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx index 476399a3d..1864978f6 100644 --- a/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx +++ b/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx @@ -6,8 +6,8 @@ import { AdCard } from "@/components/search/ad-card"; import { ServiceTags } from "@/components/search/service-tags"; import { SearchFilters } from "@/components/search/search-filters"; -import { apiProvidersGet4, apiCategoryGet } from "@/lib/api/generated/sdk.gen"; -import type { ApiProvidersGet4Data } from "@/lib/api/generated"; +import { apiProvidersGet5, apiCategoryGet } from "@/lib/api/generated/sdk.gen"; +import type { ApiProvidersGet5Data } from "@/lib/api/generated"; import { mapSearchableProviderToProvider } from "@/lib/api/mappers"; import { geocodeCity } from "@/lib/services/geocoding"; import { getAuthHeaders } from "@/lib/api/auth-headers"; @@ -75,7 +75,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { // Fetch providers from API // TODO: Implement pagination controls. Currently hardcoded to page 1. const headers = await getAuthHeaders(); - const { data, error } = await apiProvidersGet4({ + const { data, error } = await apiProvidersGet5({ query: { latitude, longitude, @@ -85,7 +85,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { minRating: minRatingVal, page: 1, pageSize: 20, - } as ApiProvidersGet4Data["query"], + } as ApiProvidersGet5Data["query"], headers, }); From 8fdf42c75b2ec7aa61204ccc93ea12ed73c68e1c Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Tue, 17 Mar 2026 20:02:09 -0300 Subject: [PATCH 05/24] feat: implement provider search page with filtering, geocoding, and results display. --- src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx b/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx index 1864978f6..476399a3d 100644 --- a/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx +++ b/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx @@ -6,8 +6,8 @@ import { AdCard } from "@/components/search/ad-card"; import { ServiceTags } from "@/components/search/service-tags"; import { SearchFilters } from "@/components/search/search-filters"; -import { apiProvidersGet5, apiCategoryGet } from "@/lib/api/generated/sdk.gen"; -import type { ApiProvidersGet5Data } from "@/lib/api/generated"; +import { apiProvidersGet4, apiCategoryGet } from "@/lib/api/generated/sdk.gen"; +import type { ApiProvidersGet4Data } from "@/lib/api/generated"; import { mapSearchableProviderToProvider } from "@/lib/api/mappers"; import { geocodeCity } from "@/lib/services/geocoding"; import { getAuthHeaders } from "@/lib/api/auth-headers"; @@ -75,7 +75,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { // Fetch providers from API // TODO: Implement pagination controls. Currently hardcoded to page 1. const headers = await getAuthHeaders(); - const { data, error } = await apiProvidersGet5({ + const { data, error } = await apiProvidersGet4({ query: { latitude, longitude, @@ -85,7 +85,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { minRating: minRatingVal, page: 1, pageSize: 20, - } as ApiProvidersGet5Data["query"], + } as ApiProvidersGet4Data["query"], headers, }); From 04b8b9a7a38741dc4eda0e1a32d401d047750b34 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Tue, 17 Mar 2026 21:03:17 -0300 Subject: [PATCH 06/24] feat: Introduce `SearchableProvider` entity for optimized search and add unit tests for public provider retrieval by ID or slug. --- .../GetPublicProviderByIdQueryHandlerTests.cs | 1 + .../Domain/Entities/SearchableProvider.cs | 27 +++++++++++-------- .../SearchProvidersIntegrationTestBase.cs | 4 +-- .../Utilities/Constants/ApiEndpoints.cs | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs index 01946cef6..a2cfe92ef 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs @@ -84,6 +84,7 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNull(); result.Value!.Id.Should().Be(provider.Id); + result.Value.Slug.Should().Be(provider.Slug); } [Fact] diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 26408b00b..be6ff2a62 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -113,10 +113,7 @@ public static SearchableProvider Create( throw new ArgumentException("Provider name cannot be empty.", nameof(name)); } - if (string.IsNullOrWhiteSpace(slug)) - { - throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); - } + slug = NormalizeAndValidateSlug(slug); ArgumentNullException.ThrowIfNull(location); @@ -127,7 +124,7 @@ public static SearchableProvider Create( location, subscriptionTier) { - Slug = SlugHelper.Generate(slug), + Slug = slug, Description = description?.Trim(), City = city?.Trim(), State = state?.Trim() @@ -167,7 +164,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = SlugHelper.Generate(slug ?? string.Empty), + Slug = NormalizeAndValidateSlug(slug), Description = description, City = city, State = state, @@ -190,13 +187,10 @@ public void UpdateBasicInfo(string name, string slug, string? description, strin throw new ArgumentException("Provider name cannot be empty.", nameof(name)); } - if (string.IsNullOrWhiteSpace(slug)) - { - throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); - } + slug = NormalizeAndValidateSlug(slug); Name = name.Trim(); - Slug = SlugHelper.Generate(slug); + Slug = slug; Description = description?.Trim(); City = city?.Trim(); State = state?.Trim(); @@ -281,4 +275,15 @@ public double CalculateDistanceToInKm(GeoPoint targetLocation) return Location.DistanceTo(targetLocation); } + + private static string NormalizeAndValidateSlug(string? slug) + { + var normalized = SlugHelper.Generate(slug ?? string.Empty); + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new ArgumentException("O identificador do provedor não pode estar vazio.", nameof(slug)); + } + + return normalized; + } } diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 217116a08..352d8ce9b 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -165,7 +165,7 @@ protected SearchableProvider CreateTestSearchableProvider( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), + slug: BuildTestSlug(name, providerId), location: location, subscriptionTier: tier, description: description, @@ -193,7 +193,7 @@ protected SearchableProvider CreateTestSearchableProviderWithProviderId( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), + slug: BuildTestSlug(name, providerId), location: location, subscriptionTier: tier, description: description, diff --git a/src/Shared/Utilities/Constants/ApiEndpoints.cs b/src/Shared/Utilities/Constants/ApiEndpoints.cs index b44dbfede..1d4b9b92f 100644 --- a/src/Shared/Utilities/Constants/ApiEndpoints.cs +++ b/src/Shared/Utilities/Constants/ApiEndpoints.cs @@ -49,7 +49,7 @@ public static class Providers public const string AddDocument = "/{id:guid}/documents"; // POST AddDocumentEndpoint public const string RemoveDocument = "/{id:guid}/documents/{documentType}"; // DELETE RemoveDocumentEndpoint public const string RequireBasicInfoCorrection = "/{id:guid}/require-basic-info-correction"; // POST RequireBasicInfoCorrectionEndpoint - public const string GetPublicByIdOrSlug = "/public/{idOrSlug}"; // GET GetPublicProviderByIdEndpoint + public const string GetPublicByIdOrSlug = "/public/{idOrSlug}"; // GET GetPublicProviderByIdOrSlugEndpoint } /// From a6285c6017fd0bbcb2fad51d4546133069e9afce Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 08:32:16 -0300 Subject: [PATCH 07/24] feat: Add SearchProviders integration test base with Testcontainers and unit tests for GetPublicProviderByIdOrSlug query handler. --- ...cs => GetPublicProviderByIdOrSlugQueryHandlerTests.cs} | 4 ++-- .../Integration/SearchProvidersIntegrationTestBase.cs | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) rename src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/{GetPublicProviderByIdQueryHandlerTests.cs => GetPublicProviderByIdOrSlugQueryHandlerTests.cs} (98%) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs similarity index 98% rename from src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs rename to src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index a2cfe92ef..d705d85f9 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -14,13 +14,13 @@ namespace MeAjudaAi.Modules.Providers.Tests.Unit.Application.Handlers.Queries; [Trait("Category", "Unit")] -public class GetPublicProviderByIdQueryHandlerTests +public class GetPublicProviderByIdOrSlugQueryHandlerTests { private readonly Mock _providerRepositoryMock; private readonly Mock _featureManagerMock; private readonly GetPublicProviderByIdOrSlugQueryHandler _handler; - public GetPublicProviderByIdQueryHandlerTests() + public GetPublicProviderByIdOrSlugQueryHandlerTests() { _providerRepositoryMock = new Mock(); _featureManagerMock = new Mock(); diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 352d8ce9b..1f6d9f354 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -273,4 +273,12 @@ protected IServiceScope CreateScope() throw new InvalidOperationException("Service provider not initialized"); return _serviceProvider.CreateScope(); } + + /// + /// Constrói o slug usado nos testes a partir do nome e do ID do provedor. + /// + private static string BuildTestSlug(string name, Guid providerId) + { + return SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]); + } } From 850f2a6550bc4fd0e7c099a94cf41822c591ded9 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 09:36:04 -0300 Subject: [PATCH 08/24] feat: Add SearchProviders integration test base with Testcontainers PostgreSQL/PostGIS, new unit tests for SearchableProvider and API extensions, and a new GetPublicProviderByIdOrSlug query handler with its tests. --- ...GetPublicProviderByIdOrSlugQueryHandler.cs | 18 ++++- ...blicProviderByIdOrSlugQueryHandlerTests.cs | 77 ++++++++++++++++++- .../SearchProvidersIntegrationTestBase.cs | 7 +- .../Entities/SearchableProviderTests.cs | 4 +- .../Unit/API/Extensions/APIExtensionsTests.cs | 15 ++-- 5 files changed, 106 insertions(+), 15 deletions(-) diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs index 5c25cfae0..2fab0248e 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs @@ -32,10 +32,20 @@ public GetPublicProviderByIdOrSlugQueryHandler(IProviderRepository providerRepos GetPublicProviderByIdOrSlugQuery query, CancellationToken cancellationToken) { - // Resolve por ID (GUID) ou slug — mesma lógica do padrão de filmes - var provider = Guid.TryParse(query.IdOrSlug, out var id) - ? await _providerRepository.GetByIdAsync(new ProviderId(id), cancellationToken) - : await _providerRepository.GetBySlugAsync(query.IdOrSlug, cancellationToken); + var normalizedValue = query.IdOrSlug.Trim().ToLowerInvariant(); + + // Tenta resolver por ID (GUID); se não encontrar, faz fallback para slug. + // Isso cobre o caso em que um slug tem formato de GUID válido. + Domain.Entities.Provider? provider; + if (Guid.TryParse(normalizedValue, out var id)) + { + provider = await _providerRepository.GetByIdAsync(new ProviderId(id), cancellationToken) + ?? await _providerRepository.GetBySlugAsync(normalizedValue, cancellationToken); + } + else + { + provider = await _providerRepository.GetBySlugAsync(normalizedValue, cancellationToken); + } if (provider is null) { diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index d705d85f9..f75c58605 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -69,10 +69,13 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() // Bypass domain transitions to set Active status directly for test var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); + statusProp.Should().NotBeNull("Provider.Status property must exist"); statusProp!.SetValue(provider, EProviderStatus.Active); + var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); + _providerRepositoryMock - .Setup(x => x.GetBySlugAsync(provider.Slug, It.IsAny())) + .Setup(x => x.GetBySlugAsync(normalizedSlug, It.IsAny())) .ReturnsAsync(provider); var query = new GetPublicProviderByIdOrSlugQuery(provider.Slug); @@ -87,6 +90,78 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() result.Value.Slug.Should().Be(provider.Slug); } + [Fact] + public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalizeAndReturnDto() + { + // Arrange + var provider = ProviderBuilder.Create() + .WithType(EProviderType.Individual) + .WithVerificationStatus(EVerificationStatus.Verified) + .Build(); + + // Bypass domain transitions to set Active status directly for test + var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); + statusProp.Should().NotBeNull("Provider.Status property must exist"); + statusProp!.SetValue(provider, EProviderStatus.Active); + + var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); + var upperSlug = provider.Slug.ToUpperInvariant(); + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(normalizedSlug, It.IsAny())) + .ReturnsAsync(provider); + + // Query uses uppercase slug — handler must normalize before calling GetBySlugAsync + var query = new GetPublicProviderByIdOrSlugQuery(upperSlug); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(provider.Id); + result.Value.Slug.Should().Be(provider.Slug); + } + + [Fact] + public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLookup() + { + // Arrange — slug that happens to be a valid GUID format (Guid.TryParse returns true) + var slugGuid = Guid.NewGuid(); + var slugValue = slugGuid.ToString().ToLowerInvariant(); // e.g. "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + var provider = ProviderBuilder.Create() + .WithType(EProviderType.Individual) + .WithVerificationStatus(EVerificationStatus.Verified) + .Build(); + + // Bypass domain transitions to set Active status directly for test + var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); + statusProp.Should().NotBeNull("Provider.Status property must exist"); + statusProp!.SetValue(provider, EProviderStatus.Active); + + // GetByIdAsync returns null (no provider with that ID) + _providerRepositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Provider?)null); + + // Fallback to slug lookup returns the provider + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(slugValue, It.IsAny())) + .ReturnsAsync(provider); + + var query = new GetPublicProviderByIdOrSlugQuery(slugValue); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(provider.Id); + } + [Fact] public async Task HandleAsync_WhenProviderIsNotActive_ShouldReturnNotFound() { diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 1f6d9f354..4686e0fd3 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -274,11 +274,16 @@ protected IServiceScope CreateScope() return _serviceProvider.CreateScope(); } + /// + /// Número de caracteres do GUID usados como sufixo (deve coincidir com SlugHelper.GenerateWithSuffix). + /// + private const int GuidSuffixLength = 8; + /// /// Constrói o slug usado nos testes a partir do nome e do ID do provedor. /// private static string BuildTestSlug(string name, Guid providerId) { - return SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]); + return SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..GuidSuffixLength]); } } diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index 0c2bb431b..deb375462 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -89,7 +89,7 @@ public void Create_WithEmptySlug_ShouldThrowArgumentException() // Assert act.Should().Throw() - .WithMessage("*Provider slug cannot be empty*"); + .WithMessage("*O identificador do provedor não pode estar vazio.*"); } [Fact] @@ -131,7 +131,7 @@ public void UpdateBasicInfo_WithEmptySlug_ShouldThrowArgumentException() // Assert act.Should().Throw() - .WithMessage("*Provider slug cannot be empty*"); + .WithMessage("*O identificador do provedor não pode estar vazio.*"); } [Fact] diff --git a/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs index 9345beaa0..fff478b58 100644 --- a/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs @@ -1,4 +1,5 @@ using MeAjudaAi.Modules.Users.API; +using MeAjudaAi.Shared.Database; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -22,7 +23,7 @@ public void AddUsersModule_ShouldRegisterServices() var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=user;Password=pass", + ["ConnectionStrings:Users"] = DatabaseConstants.DefaultTestConnectionString, ["Database:EnableSchemaIsolation"] = "false" }) .Build(); @@ -47,7 +48,7 @@ public async Task AddUsersModuleWithSchemaIsolationAsync_WithSchemaIsolationDisa var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=user;Password=pass", + ["ConnectionStrings:Users"] = DatabaseConstants.DefaultTestConnectionString, ["Database:EnableSchemaIsolation"] = "false" }) .Build(); @@ -68,7 +69,7 @@ public async Task AddUsersModuleWithSchemaIsolationAsync_WithNullPasswords_Shoul var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=user;Password=pass", + ["ConnectionStrings:Users"] = DatabaseConstants.DefaultTestConnectionString, ["Database:EnableSchemaIsolation"] = "false" }) .Build(); @@ -109,7 +110,7 @@ public void AddUsersModule_WithNullConfiguration_ShouldThrow() // Act & Assert var act = () => services.AddUsersModule(configuration); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -145,7 +146,7 @@ public async Task AddUsersModuleWithSchemaIsolationAsync_WithNullConfiguration_S // Act & Assert var act = async () => await services.AddUsersModuleWithSchemaIsolationAsync(configuration); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } [Fact] @@ -166,7 +167,7 @@ public void AddUsersModule_WithValidConfiguration_ShouldReturnSameServiceCollect var services = new ServiceCollection(); var configData = new Dictionary { - ["Database:ConnectionString"] = "Server=localhost;Database=TestDb;Trusted_Connection=true;", + ["ConnectionStrings:Users"] = DatabaseConstants.DefaultTestConnectionString, ["Cache:RedisConnectionString"] = "localhost:6379" }; var configuration = new ConfigurationBuilder() @@ -191,7 +192,7 @@ public void AddUsersModule_CalledMultipleTimes_ShouldNotThrow() var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=user;Password=pass" + ["ConnectionStrings:Users"] = DatabaseConstants.DefaultTestConnectionString }) .Build(); From 71d3640d7040c93f6190ceb089d28b530301297f Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 11:03:07 -0300 Subject: [PATCH 09/24] feat: Introduce `SearchableProvider` entity for search optimization and add `GetPublicProviderByIdOrSlugQueryHandler` with public profile privacy features. --- ...GetPublicProviderByIdOrSlugQueryHandler.cs | 2 +- ...blicProviderByIdOrSlugQueryHandlerTests.cs | 27 +++++++++---------- .../Domain/Entities/SearchableProvider.cs | 2 +- src/Modules/Users/API/Extensions.cs | 6 +++++ .../Unit/API/Extensions/APIExtensionsTests.cs | 4 +-- .../Users/Tests/Unit/API/ExtensionsTests.cs | 27 +++++++++++-------- 6 files changed, 38 insertions(+), 30 deletions(-) diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs index 2fab0248e..5415b5c23 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs @@ -73,7 +73,7 @@ public GetPublicProviderByIdOrSlugQueryHandler(IProviderRepository providerRepos ? businessProfile.ContactInfo.Email : null; - var services = !isPrivacyEnabled + var services = !shouldRedactContactInfo ? provider.Services.Select(s => s.ServiceName).ToList() : new List(); diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index f75c58605..cad2ca19b 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -37,9 +37,7 @@ public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificati .Build(); // Bypass domain transitions to set Active status directly for test - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, EProviderStatus.Active); + SetProviderStatus(provider, EProviderStatus.Active); _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) @@ -68,9 +66,7 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() .Build(); // Bypass domain transitions to set Active status directly for test - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, EProviderStatus.Active); + SetProviderStatus(provider, EProviderStatus.Active); var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); @@ -100,9 +96,7 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize .Build(); // Bypass domain transitions to set Active status directly for test - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, EProviderStatus.Active); + SetProviderStatus(provider, EProviderStatus.Active); var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); var upperSlug = provider.Slug.ToUpperInvariant(); @@ -137,9 +131,7 @@ public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLook .Build(); // Bypass domain transitions to set Active status directly for test - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, EProviderStatus.Active); + SetProviderStatus(provider, EProviderStatus.Active); // GetByIdAsync returns null (no provider with that ID) _providerRepositoryMock @@ -217,9 +209,7 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro .Build(); // Bypass domain transitions to set Active status directly for test - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, EProviderStatus.Active); + SetProviderStatus(provider, EProviderStatus.Active); _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) @@ -242,4 +232,11 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro result.Value.PhoneNumbers.Should().BeEmpty(); result.Value.Services.Should().BeEmpty(); } + + private static void SetProviderStatus(Provider provider, EProviderStatus status) + { + var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); + statusProp.Should().NotBeNull("Provider.Status property must exist"); + statusProp!.SetValue(provider, status); + } } diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index be6ff2a62..e39120d7b 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -164,7 +164,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = NormalizeAndValidateSlug(slug), + Slug = SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), Description = description, City = city, State = state, diff --git a/src/Modules/Users/API/Extensions.cs b/src/Modules/Users/API/Extensions.cs index d650b5486..454ed503c 100644 --- a/src/Modules/Users/API/Extensions.cs +++ b/src/Modules/Users/API/Extensions.cs @@ -16,6 +16,9 @@ public static class Extensions { public static IServiceCollection AddUsersModule(this IServiceCollection services, IConfiguration configuration) { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + services.AddApplication(); services.AddInfrastructure(configuration); @@ -35,6 +38,9 @@ public static async Task AddUsersModuleWithSchemaIsolationAs string? usersRolePassword = null, string? appRolePassword = null) { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + // Configurar serviços do módulo services.AddUsersModule(configuration); diff --git a/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs index fff478b58..f0f91e516 100644 --- a/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs @@ -110,7 +110,7 @@ public void AddUsersModule_WithNullConfiguration_ShouldThrow() // Act & Assert var act = () => services.AddUsersModule(configuration); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -146,7 +146,7 @@ public async Task AddUsersModuleWithSchemaIsolationAsync_WithNullConfiguration_S // Act & Assert var act = async () => await services.AddUsersModuleWithSchemaIsolationAsync(configuration); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } [Fact] diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 840612836..f16de251a 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -45,19 +45,17 @@ public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() } [Fact] - public void AddUsersModule_WithEmptyConfiguration_ShouldRegisterServices() + public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationException() { // Arrange var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - // Act - Não deve lançar exceção durante o registro mesmo com configuração vazia - var result = services.AddUsersModule(configuration); - - // Assert - Assert.NotNull(result); - Assert.Same(services, result); - Assert.True(services.Count > 0, "Services should be registered even with empty configuration"); + // Act & Assert + var act = () => services.AddUsersModule(configuration); + + act.Should().Throw() + .WithMessage("Connection for Users module not configured"); } [Fact] @@ -65,7 +63,12 @@ public void AddUsersModule_ShouldReturnSameServiceCollectionInstance() { // Arrange var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder().Build(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" + }) + .Build(); // Act var result = services.AddUsersModule(configuration); @@ -109,7 +112,10 @@ public void AddUsersModule_WithMinimalConfiguration_ShouldRegisterServices() // Arrange var services = new ServiceCollection(); var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary()) + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" + }) .Build(); // Act @@ -126,7 +132,6 @@ public void AddUsersModule_WithMinimalConfiguration_ShouldRegisterServices() [Theory] [InlineData("Server=localhost;Database=test1;", "test-realm")] [InlineData("Server=localhost;Database=test2;", "another-realm")] - [InlineData("", "")] public void AddUsersModule_WithVariousConfigurations_ShouldRegisterServices(string connectionString, string realm) { // Arrange From 3658cafa1a9030631f724b95895b7dbef7cd04bd Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 11:06:58 -0300 Subject: [PATCH 10/24] feat: Introduce `SearchableProvider` entity for search optimization and add `GetPublicProviderByIdOrSlugQueryHandler` with public profile privacy features. --- .../Extensions/ServiceCollectionExtensions.cs | 1 - .../ContentSecurityPolicyMiddleware.cs | 2 +- .../{Middleware => Middlewares}/InspectAuthMiddleware.cs | 2 +- .../Unit/Middlewares/InspectAuthMiddlewareTests.cs | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) rename src/Bootstrapper/MeAjudaAi.ApiService/{Middleware => Middlewares}/ContentSecurityPolicyMiddleware.cs (99%) rename src/Bootstrapper/MeAjudaAi.ApiService/{Middleware => Middlewares}/InspectAuthMiddleware.cs (98%) diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs index 424ade1fd..ded6230af 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ using System.Security.Claims; using MeAjudaAi.ApiService.Endpoints; -using MeAjudaAi.ApiService.Middleware; using MeAjudaAi.ApiService.Middlewares; using MeAjudaAi.ApiService.Options; using MeAjudaAi.ApiService.Services.Authentication; diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middleware/ContentSecurityPolicyMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/ContentSecurityPolicyMiddleware.cs similarity index 99% rename from src/Bootstrapper/MeAjudaAi.ApiService/Middleware/ContentSecurityPolicyMiddleware.cs rename to src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/ContentSecurityPolicyMiddleware.cs index 88a026fcd..3be920f58 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middleware/ContentSecurityPolicyMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/ContentSecurityPolicyMiddleware.cs @@ -1,4 +1,4 @@ -namespace MeAjudaAi.ApiService.Middleware; +namespace MeAjudaAi.ApiService.Middlewares; /// /// Middleware para adicionar Content Security Policy headers. diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middleware/InspectAuthMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/InspectAuthMiddleware.cs similarity index 98% rename from src/Bootstrapper/MeAjudaAi.ApiService/Middleware/InspectAuthMiddleware.cs rename to src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/InspectAuthMiddleware.cs index 1da7aed81..bb007e327 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middleware/InspectAuthMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/InspectAuthMiddleware.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; -namespace MeAjudaAi.ApiService.Middleware; +namespace MeAjudaAi.ApiService.Middlewares; /// diff --git a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/InspectAuthMiddlewareTests.cs b/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/InspectAuthMiddlewareTests.cs index 88bab8706..267e93214 100644 --- a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/InspectAuthMiddlewareTests.cs +++ b/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/InspectAuthMiddlewareTests.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using FluentAssertions; -using MeAjudaAi.ApiService.Middleware; +using MeAjudaAi.ApiService.Middlewares; using MeAjudaAi.Shared.Utilities.Constants; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; From d7a0a8a0eb5cb23d6a8d133df6bc83cceb418d56 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 11:17:25 -0300 Subject: [PATCH 11/24] feat: Implement user management module with Keycloak integration and add a RabbitMQ dead letter service. --- src/Aspire/MeAjudaAi.AppHost/Program.cs | 6 +++++ .../MeAjudaAi.AppHost/packages.lock.json | 8 +++---- .../Middlewares/SecurityHeadersMiddleware.cs | 7 ++---- .../Commands/CreateUserCommandHandler.cs | 6 ++--- .../Commands/DeleteUserCommandHandler.cs | 2 +- .../RegisterCustomerCommandHandler.cs | 10 ++++---- .../UpdateUserProfileCommandHandler.cs | 2 +- .../Identity/Keycloak/KeycloakService.cs | 2 +- .../Services/KeycloakUserDomainService.cs | 4 ++-- .../LocalDevelopmentUserDomainService.cs | 2 +- .../Users/Tests/Builders/UserBuilder.cs | 2 +- .../Queries/GetUsersQueryHandlerTests.cs | 2 +- .../Tests/Unit/Domain/Entities/UserTests.cs | 2 +- src/Shared/Database/DatabaseConstants.cs | 2 ++ .../DeadLetter/RabbitMqDeadLetterService.cs | 4 ++-- .../MeAjudaAi.Web.Admin/packages.lock.json | 24 +++++++++---------- tests/MeAjudaAi.E2E.Tests/packages.lock.json | 12 +++++----- .../packages.lock.json | 12 +++++----- .../Unit/Functional/ResultTests.cs | 2 +- 19 files changed, 58 insertions(+), 53 deletions(-) diff --git a/src/Aspire/MeAjudaAi.AppHost/Program.cs b/src/Aspire/MeAjudaAi.AppHost/Program.cs index 263b77626..a9af552b8 100644 --- a/src/Aspire/MeAjudaAi.AppHost/Program.cs +++ b/src/Aspire/MeAjudaAi.AppHost/Program.cs @@ -68,7 +68,9 @@ private static void ConfigureTestingEnvironment(IDistributedApplicationBuilder b Console.Error.WriteLine("Please set MEAJUDAAI_DB_PASS to the database password in your CI environment."); Environment.Exit(1); } +#pragma warning disable S2068 testDbPassword = "test123"; +#pragma warning restore S2068 } var postgresql = builder.AddMeAjudaAiPostgreSQL(options => @@ -109,7 +111,9 @@ private static void ConfigureDevelopmentEnvironment(IDistributedApplicationBuild Console.Error.WriteLine("Please set DB_PASSWORD to the database password in your CI environment."); Environment.Exit(1); } +#pragma warning disable S2068 dbPassword = "test123"; +#pragma warning restore S2068 } var includePgAdminStr = Environment.GetEnvironmentVariable("INCLUDE_PGADMIN") ?? "true"; var includePgAdmin = !bool.TryParse(includePgAdminStr, out var pgAdminResult) || pgAdminResult; @@ -133,7 +137,9 @@ private static void ConfigureDevelopmentEnvironment(IDistributedApplicationBuild var keycloakSettings = new MeAjudaAi.AppHost.Options.MeAjudaAiKeycloakOptions { AdminUsername = "admin", +#pragma warning disable S2068 AdminPassword = "admin123", +#pragma warning restore S2068 DatabaseHost = "postgres-local", DatabasePort = "5432", DatabaseName = mainDatabase, diff --git a/src/Aspire/MeAjudaAi.AppHost/packages.lock.json b/src/Aspire/MeAjudaAi.AppHost/packages.lock.json index 35fbb0a84..f448206e8 100644 --- a/src/Aspire/MeAjudaAi.AppHost/packages.lock.json +++ b/src/Aspire/MeAjudaAi.AppHost/packages.lock.json @@ -2,11 +2,11 @@ "version": 2, "dependencies": { "net10.0": { - "Aspire.Dashboard.Sdk.linux-x64": { + "Aspire.Dashboard.Sdk.win-x64": { "type": "Direct", "requested": "[13.1.0, )", "resolved": "13.1.0", - "contentHash": "gIvMR7NdVGw+mUpK9qZsGuuYfDp4CHvBDLlykSSP9pCh5XGlMgDNR3uOMfvKVlqH1hAsOuecvxEcfQ9kjufrWw==" + "contentHash": "rrcsI8cankYCiUlj4Ev+os9uKcutUCv+9kvHQt85RiUX/ewXsloFZy0/depKWrzdJkdJuoTbYFRlSe43TKq6aQ==" }, "Aspire.Hosting.AppHost": { "type": "Direct", @@ -199,11 +199,11 @@ "System.IO.Hashing": "9.0.10" } }, - "Aspire.Hosting.Orchestration.linux-x64": { + "Aspire.Hosting.Orchestration.win-x64": { "type": "Direct", "requested": "[13.1.0, )", "resolved": "13.1.0", - "contentHash": "V8u8ukncoflImciXeHG03x1pWbOiR4z1bIl2lwOKgdy7/JshFrlEaabaNuGmAIZFdWEM/fsc/hVDjecQyHF2aQ==" + "contentHash": "3w2UahEauTq719LPJ/BCySh31kz26sfjuOkRF5E4VYy1Q3xLRV43+OIGI3C5sy8feUHjrYz+DDq3DQn/2fu+4g==" }, "Aspire.Hosting.PostgreSQL": { "type": "Direct", diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs index 02e86332f..2dd19f2ee 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs @@ -42,12 +42,9 @@ public Task InvokeAsync(HttpContext context) var headers = ctx.Response.Headers; // Adiciona cabeçalhos de segurança estáticos eficientemente - foreach (var header in StaticHeaders) + foreach (var header in StaticHeaders.Where(h => !headers.ContainsKey(h.Key))) { - if (!headers.ContainsKey(header.Key)) - { - headers.Append(header.Key, header.Value); - } + headers.Append(header.Key, header.Value); } // HSTS apenas em produção e HTTPS - usando verificação de ambiente em cache diff --git a/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs index c6cd36b2c..a59880c5f 100644 --- a/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs @@ -75,13 +75,13 @@ public async Task> HandleAsync( return Result.Failure(userResult.Error); // Persistir usuário no repositório - await PersistUserAsync(userResult.Value, stopwatch, cancellationToken); + await PersistUserAsync(userResult.Value!, stopwatch, cancellationToken); stopwatch.Stop(); logger.LogInformation("User {UserId} created successfully for email {Email} in {ElapsedMs}ms", - userResult.Value.Id, command.Email, stopwatch.ElapsedMilliseconds); + userResult.Value!.Id, command.Email, stopwatch.ElapsedMilliseconds); - return Result.Success(userResult.Value.ToDto()); + return Result.Success(userResult.Value!.ToDto()); } catch (ArgumentException) { diff --git a/src/Modules/Users/Application/Handlers/Commands/DeleteUserCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/DeleteUserCommandHandler.cs index f488487a2..442343153 100644 --- a/src/Modules/Users/Application/Handlers/Commands/DeleteUserCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/DeleteUserCommandHandler.cs @@ -66,7 +66,7 @@ public async Task HandleAsync( if (userResult.IsFailure) return Result.Failure(userResult.Error); - var user = userResult.Value; + var user = userResult.Value!; // Sincronizar com Keycloak var syncResult = await SyncWithKeycloakAsync(user, cancellationToken); diff --git a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs index e91cd10cd..48b5bc9e5 100644 --- a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs @@ -116,7 +116,7 @@ public async Task> HandleAsync(RegisterCustomerCommand command, try { - await userRepository.AddAsync(userResult.Value, cancellationToken); + await userRepository.AddAsync(userResult.Value!, cancellationToken); } catch (Exception ex) { @@ -127,12 +127,12 @@ public async Task> HandleAsync(RegisterCustomerCommand command, else { logger.LogError(ex, "Failed to persist customer {Email} ({Id}) to repository. Attempting Keycloak compensation.", - maskedEmail, userResult.Value.Id); + maskedEmail, userResult.Value!.Id); } // Verifica se o usuário realmente não foi salvo no repositório antes da compensação // Usamos CancellationToken.None para garantir que a compensação ocorra mesmo se o request original foi cancelado - var persistenceCheck = await userRepository.GetByIdNoTrackingAsync(userResult.Value.Id, CancellationToken.None); + var persistenceCheck = await userRepository.GetByIdNoTrackingAsync(userResult.Value!.Id, CancellationToken.None); if (persistenceCheck == null) { // Compensação: desativar o usuário criado no Keycloak para evitar usuário órfão "fantasma" que pode logar mas não tem dados locais @@ -166,8 +166,8 @@ public async Task> HandleAsync(RegisterCustomerCommand command, return Result.Failure(Error.Internal("Falha ao salvar o cadastro. Tente novamente mais tarde.")); } - logger.LogInformation("Customer registered successfully: {Email} ({Id})", maskedEmail, userResult.Value.Id); + logger.LogInformation("Customer registered successfully: {Email} ({Id})", maskedEmail, userResult.Value!.Id); - return Result.Success(userResult.Value.ToDto()); + return Result.Success(userResult.Value!.ToDto()); } } diff --git a/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs index e04137924..c831db7ae 100644 --- a/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs @@ -62,7 +62,7 @@ public async Task> HandleAsync( if (userResult.IsFailure) return Result.Failure(userResult.Error); - var user = userResult.Value; + var user = userResult.Value!; // Aplicar atualização do perfil ApplyProfileUpdate(command, user); diff --git a/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs b/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs index 196715b23..b904a40fb 100644 --- a/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs +++ b/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs @@ -95,7 +95,7 @@ public async Task> CreateUserAsync( // Atribui papéis se fornecidos if (roles.Any()) { - var roleAssignResult = await AssignRolesToUserAsync(keycloakUserId, roles, adminToken.Value, cancellationToken); + var roleAssignResult = await AssignRolesToUserAsync(keycloakUserId, roles, adminToken.Value!, cancellationToken); if (roleAssignResult.IsFailure) { logger.LogWarning("User created but role assignment failed: {Error}", roleAssignResult.Error); diff --git a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs index 0f76ff230..353625d33 100644 --- a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs +++ b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs @@ -63,7 +63,7 @@ public async Task> CreateUserAsync( return Result.Failure(keycloakResult.Error); // Cria a entidade User local com o ID retornado pelo Keycloak - var userResult = User.Create(username, email, firstName, lastName, keycloakResult.Value, phoneNumber); + var userResult = User.Create(username, email, firstName, lastName, keycloakResult.Value!, phoneNumber); if (userResult.IsFailure) { var deactivationResult = await keycloakService.DeactivateUserAsync(keycloakResult.Value, CancellationToken.None); @@ -75,7 +75,7 @@ public async Task> CreateUserAsync( return Result.Failure(userResult.Error); } - return Result.Success(userResult.Value); + return Result.Success(userResult.Value!); } /// diff --git a/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs b/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs index 7a4bfd926..d5c9448d8 100644 --- a/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs +++ b/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs @@ -31,7 +31,7 @@ public Task> CreateUserAsync( // Using UuidGenerator.NewId() for better time-based ordering and performance var userResult = User.Create(username, email, firstName, lastName, UuidGenerator.NewId().ToString(), phoneNumber); if (userResult.IsFailure) return Task.FromResult(Result.Failure(userResult.Error)); - return Task.FromResult(Result.Success(userResult.Value)); + return Task.FromResult(Result.Success(userResult.Value!)); } /// diff --git a/src/Modules/Users/Tests/Builders/UserBuilder.cs b/src/Modules/Users/Tests/Builders/UserBuilder.cs index 48308cbbe..e7a29419d 100644 --- a/src/Modules/Users/Tests/Builders/UserBuilder.cs +++ b/src/Modules/Users/Tests/Builders/UserBuilder.cs @@ -42,7 +42,7 @@ public UserBuilder() user.SetIdForTesting(new UserId(_id.Value)); } - return user; + return user!; }); } diff --git a/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs b/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs index ad5909d6a..bf23bcb19 100644 --- a/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs +++ b/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs @@ -245,6 +245,6 @@ private static User CreateTestUser(string username, string email, string firstNa firstName: firstName, lastName: lastName, keycloakId: Guid.NewGuid().ToString() - ).Value; + ).Value!; } } diff --git a/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs b/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs index 4883e68d1..ea51bdac9 100644 --- a/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs +++ b/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs @@ -409,6 +409,6 @@ private static User CreateTestUser(string firstName = "John", string lastName = firstName, lastName, Guid.NewGuid().ToString() - ).Value; + ).Value!; } } diff --git a/src/Shared/Database/DatabaseConstants.cs b/src/Shared/Database/DatabaseConstants.cs index a15944cfe..a2328375d 100644 --- a/src/Shared/Database/DatabaseConstants.cs +++ b/src/Shared/Database/DatabaseConstants.cs @@ -5,5 +5,7 @@ public static class DatabaseConstants /// /// String de conexão padrão para ambientes de teste e desenvolvimento local (fallback). /// +#pragma warning disable S2068 // "password" detected here, make sure this is not a hard-coded credential public const string DefaultTestConnectionString = "Host=localhost;Database=test;Username=test;Password=test"; +#pragma warning restore S2068 // "password" detected here, make sure this is not a hard-coded credential } diff --git a/src/Shared/Messaging/DeadLetter/RabbitMqDeadLetterService.cs b/src/Shared/Messaging/DeadLetter/RabbitMqDeadLetterService.cs index 070333bae..2438a5f06 100644 --- a/src/Shared/Messaging/DeadLetter/RabbitMqDeadLetterService.cs +++ b/src/Shared/Messaging/DeadLetter/RabbitMqDeadLetterService.cs @@ -294,14 +294,14 @@ public async Task GetDeadLetterStatisticsAsync(Cancellatio private async Task EnsureConnectionAsync() { - if (_disposed) throw new ObjectDisposedException(nameof(RabbitMqDeadLetterService)); + ObjectDisposedException.ThrowIf(_disposed, this); if (_connection?.IsOpen == true && _channel?.IsOpen == true) return; await _connectionSemaphore.WaitAsync(_disposeCts.Token); try { - if (_disposed) throw new ObjectDisposedException(nameof(RabbitMqDeadLetterService)); + ObjectDisposedException.ThrowIf(_disposed, this); if (_connection?.IsOpen == true && _channel?.IsOpen == true) return; diff --git a/src/Web/MeAjudaAi.Web.Admin/packages.lock.json b/src/Web/MeAjudaAi.Web.Admin/packages.lock.json index c915cb485..8e99a3310 100644 --- a/src/Web/MeAjudaAi.Web.Admin/packages.lock.json +++ b/src/Web/MeAjudaAi.Web.Admin/packages.lock.json @@ -52,9 +52,9 @@ }, "Microsoft.AspNetCore.App.Internal.Assets": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "mr3Zn+ht8lijYvlMIasftw9opU9hsLKDdnOgQMmYI3RjWPJLOF9l8+YHDseRkTs97wOrULmJgo/NDCmzL/EGDg==" + "requested": "[10.0.4, )", + "resolved": "10.0.4", + "contentHash": "M942X5Vy726SlvFBuoAC4cDczEMlPAFt1mmyFlrkw/QcpdVwVU0DkF4P9JabxX6eWNm9RvaYZHe25FN7oXoxpQ==" }, "Microsoft.AspNetCore.Components.WebAssembly": { "type": "Direct", @@ -87,9 +87,9 @@ }, "Microsoft.DotNet.HotReload.WebAssembly.Browser": { "type": "Direct", - "requested": "[10.0.103, )", - "resolved": "10.0.103", - "contentHash": "tIq6T4oyBMjxxxllslJu2fOJqEj8SOW29IfWqHYxubreOVlFWnxPN2PjDMuwoHjkrOnPKyuqoNcVYh5YOZcs0Q==" + "requested": "[10.0.104, )", + "resolved": "10.0.104", + "contentHash": "OkWFygvFdm9emwxRW5ahcsU6saKO9EOYCFVNEXWl+23A4ssZy40VCQuqPhyurU7IyaPEhgA4iXh0/o7fhyNngA==" }, "Microsoft.Extensions.Http.Resilience": { "type": "Direct", @@ -104,15 +104,15 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + "requested": "[10.0.4, )", + "resolved": "10.0.4", + "contentHash": "CCx8ojW3mOL150/LnP0DK7qpMrJEt6xxNCmJFKoX89v1h0FwpsEHqennowGPYDxp6zIkIO4f9PxynjOeLF+1zw==" }, "Microsoft.NET.Sdk.WebAssembly.Pack": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "Wv9ugg75HxuYeBxJSoK2UPN6lpqsASkyZwd90bdRyVKOtaXZFaKg7R+dYqbZSHJXAIxC1HOcYR0imuIcKpI1yw==" + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "8XSP0Wu0xpj3xr88WwW43GiklXXiM27n6V8CXk35wT1MjxCfmudQJYmLbirWiPiqU1vCIWTIczPpI40ivC3ruQ==" }, "MudBlazor": { "type": "Direct", diff --git a/tests/MeAjudaAi.E2E.Tests/packages.lock.json b/tests/MeAjudaAi.E2E.Tests/packages.lock.json index afda9b7ea..40f847229 100644 --- a/tests/MeAjudaAi.E2E.Tests/packages.lock.json +++ b/tests/MeAjudaAi.E2E.Tests/packages.lock.json @@ -144,10 +144,10 @@ "Microsoft.Extensions.Primitives": "8.0.0" } }, - "Aspire.Dashboard.Sdk.linux-x64": { + "Aspire.Dashboard.Sdk.win-x64": { "type": "Transitive", "resolved": "13.1.0", - "contentHash": "gIvMR7NdVGw+mUpK9qZsGuuYfDp4CHvBDLlykSSP9pCh5XGlMgDNR3uOMfvKVlqH1hAsOuecvxEcfQ9kjufrWw==" + "contentHash": "rrcsI8cankYCiUlj4Ev+os9uKcutUCv+9kvHQt85RiUX/ewXsloFZy0/depKWrzdJkdJuoTbYFRlSe43TKq6aQ==" }, "Aspire.Hosting": { "type": "Transitive", @@ -374,10 +374,10 @@ "System.IO.Hashing": "9.0.10" } }, - "Aspire.Hosting.Orchestration.linux-x64": { + "Aspire.Hosting.Orchestration.win-x64": { "type": "Transitive", "resolved": "13.1.0", - "contentHash": "V8u8ukncoflImciXeHG03x1pWbOiR4z1bIl2lwOKgdy7/JshFrlEaabaNuGmAIZFdWEM/fsc/hVDjecQyHF2aQ==" + "contentHash": "3w2UahEauTq719LPJ/BCySh31kz26sfjuOkRF5E4VYy1Q3xLRV43+OIGI3C5sy8feUHjrYz+DDq3DQn/2fu+4g==" }, "AspNetCore.HealthChecks.Rabbitmq": { "type": "Transitive", @@ -1609,13 +1609,13 @@ "meajudaai.apphost": { "type": "Project", "dependencies": { - "Aspire.Dashboard.Sdk.linux-x64": "[13.1.0, )", + "Aspire.Dashboard.Sdk.win-x64": "[13.1.0, )", "Aspire.Hosting.AppHost": "[13.1.0, )", "Aspire.Hosting.Azure.AppContainers": "[13.1.2, )", "Aspire.Hosting.Azure.PostgreSQL": "[13.1.2, )", "Aspire.Hosting.JavaScript": "[13.1.2, )", "Aspire.Hosting.Keycloak": "[13.1.0-preview.1.25616.3, )", - "Aspire.Hosting.Orchestration.linux-x64": "[13.1.0, )", + "Aspire.Hosting.Orchestration.win-x64": "[13.1.0, )", "Aspire.Hosting.PostgreSQL": "[13.1.2, )", "Aspire.Hosting.RabbitMQ": "[13.1.2, )", "Aspire.Hosting.Redis": "[13.1.2, )", diff --git a/tests/MeAjudaAi.Integration.Tests/packages.lock.json b/tests/MeAjudaAi.Integration.Tests/packages.lock.json index 332c263f6..60dabdae8 100644 --- a/tests/MeAjudaAi.Integration.Tests/packages.lock.json +++ b/tests/MeAjudaAi.Integration.Tests/packages.lock.json @@ -200,10 +200,10 @@ "Microsoft.Extensions.Primitives": "8.0.0" } }, - "Aspire.Dashboard.Sdk.linux-x64": { + "Aspire.Dashboard.Sdk.win-x64": { "type": "Transitive", "resolved": "13.1.0", - "contentHash": "gIvMR7NdVGw+mUpK9qZsGuuYfDp4CHvBDLlykSSP9pCh5XGlMgDNR3uOMfvKVlqH1hAsOuecvxEcfQ9kjufrWw==" + "contentHash": "rrcsI8cankYCiUlj4Ev+os9uKcutUCv+9kvHQt85RiUX/ewXsloFZy0/depKWrzdJkdJuoTbYFRlSe43TKq6aQ==" }, "Aspire.Hosting": { "type": "Transitive", @@ -430,10 +430,10 @@ "System.IO.Hashing": "9.0.10" } }, - "Aspire.Hosting.Orchestration.linux-x64": { + "Aspire.Hosting.Orchestration.win-x64": { "type": "Transitive", "resolved": "13.1.0", - "contentHash": "V8u8ukncoflImciXeHG03x1pWbOiR4z1bIl2lwOKgdy7/JshFrlEaabaNuGmAIZFdWEM/fsc/hVDjecQyHF2aQ==" + "contentHash": "3w2UahEauTq719LPJ/BCySh31kz26sfjuOkRF5E4VYy1Q3xLRV43+OIGI3C5sy8feUHjrYz+DDq3DQn/2fu+4g==" }, "AspNetCore.HealthChecks.Rabbitmq": { "type": "Transitive", @@ -2528,13 +2528,13 @@ "meajudaai.apphost": { "type": "Project", "dependencies": { - "Aspire.Dashboard.Sdk.linux-x64": "[13.1.0, )", + "Aspire.Dashboard.Sdk.win-x64": "[13.1.0, )", "Aspire.Hosting.AppHost": "[13.1.0, )", "Aspire.Hosting.Azure.AppContainers": "[13.1.2, )", "Aspire.Hosting.Azure.PostgreSQL": "[13.1.2, )", "Aspire.Hosting.JavaScript": "[13.1.2, )", "Aspire.Hosting.Keycloak": "[13.1.0-preview.1.25616.3, )", - "Aspire.Hosting.Orchestration.linux-x64": "[13.1.0, )", + "Aspire.Hosting.Orchestration.win-x64": "[13.1.0, )", "Aspire.Hosting.PostgreSQL": "[13.1.2, )", "Aspire.Hosting.RabbitMQ": "[13.1.2, )", "Aspire.Hosting.Redis": "[13.1.2, )", diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/Functional/ResultTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/Functional/ResultTests.cs index a5c22d992..534aa410a 100644 --- a/tests/MeAjudaAi.Shared.Tests/Unit/Functional/ResultTests.cs +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Functional/ResultTests.cs @@ -148,7 +148,7 @@ public void Constructor_WithValidParameters_ShouldCreateResultCorrectly() // Act var successResult = new Result(true, value, null); - var failureResult = new Result(false, default, error); + var failureResult = new Result(false, default!, error); // Assert successResult.IsSuccess.Should().BeTrue(); From 6647285735ccaa92120363181b00f86403eacc6f Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 12:57:16 -0300 Subject: [PATCH 12/24] feat: Implement customer registration with Keycloak integration, add provider query handlers, a searchable provider entity, and security headers middleware. --- src/Aspire/MeAjudaAi.AppHost/Program.cs | 1 + .../Middlewares/SecurityHeadersMiddleware.cs | 9 ++- .../Queries/GetProviderByIdQueryHandler.cs | 3 +- ...GetPublicProviderByIdOrSlugQueryHandler.cs | 9 +-- .../Domain/Constants/ProviderErrors.cs | 6 ++ ...blicProviderByIdOrSlugQueryHandlerTests.cs | 38 +++++++++- .../Domain/Entities/SearchableProvider.cs | 2 +- .../RegisterCustomerCommandHandler.cs | 26 ++++--- .../Services/KeycloakUserDomainService.cs | 7 +- .../Users/Tests/Unit/API/ExtensionsTests.cs | 71 ++++++++++++++----- 10 files changed, 134 insertions(+), 38 deletions(-) create mode 100644 src/Modules/Providers/Domain/Constants/ProviderErrors.cs diff --git a/src/Aspire/MeAjudaAi.AppHost/Program.cs b/src/Aspire/MeAjudaAi.AppHost/Program.cs index a9af552b8..567891c9d 100644 --- a/src/Aspire/MeAjudaAi.AppHost/Program.cs +++ b/src/Aspire/MeAjudaAi.AppHost/Program.cs @@ -68,6 +68,7 @@ private static void ConfigureTestingEnvironment(IDistributedApplicationBuilder b Console.Error.WriteLine("Please set MEAJUDAAI_DB_PASS to the database password in your CI environment."); Environment.Exit(1); } +// Suppress S2068: intentional hardcoded dev/test default credential, only used in local/dev scenarios; CI requires env var #pragma warning disable S2068 testDbPassword = "test123"; #pragma warning restore S2068 diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs index 2dd19f2ee..09241510f 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs @@ -42,10 +42,15 @@ public Task InvokeAsync(HttpContext context) var headers = ctx.Response.Headers; // Adiciona cabeçalhos de segurança estáticos eficientemente - foreach (var header in StaticHeaders.Where(h => !headers.ContainsKey(h.Key))) +#pragma warning disable S3267 // Loops should be simplified with "Where" LINQ method - avoiding LINQ allocations on hot path as requested + foreach (var header in StaticHeaders) { - headers.Append(header.Key, header.Value); + if (!headers.ContainsKey(header.Key)) + { + headers.Append(header.Key, header.Value); + } } +#pragma warning restore S3267 // HSTS apenas em produção e HTTPS - usando verificação de ambiente em cache if (ctx.Request.IsHttps && !_isDevelopment && !headers.ContainsKey(HstsHeaderName)) diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs index 1fa38393c..9fd515522 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs @@ -3,6 +3,7 @@ using MeAjudaAi.Modules.Providers.Application.Queries; using MeAjudaAi.Modules.Providers.Domain.Repositories; using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Modules.Providers.Domain.Constants; using MeAjudaAi.Contracts.Functional; using MeAjudaAi.Shared.Queries; using Microsoft.Extensions.Logging; @@ -34,7 +35,7 @@ ILogger logger if (provider == null) { logger.LogWarning("Provider {ProviderId} not found", query.ProviderId); - return Result.Failure(Error.NotFound("Prestador não encontrado")); + return Result.Failure(Error.NotFound(ProviderErrors.ProviderNotFound)); } logger.LogInformation("Provider {ProviderId} found successfully", query.ProviderId); diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs index 5415b5c23..59d1ed176 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs @@ -8,6 +8,7 @@ using MeAjudaAi.Modules.Providers.Domain.ValueObjects; using MeAjudaAi.Shared.Queries; using MeAjudaAi.Shared.Utilities.Constants; +using MeAjudaAi.Modules.Providers.Domain.Constants; using Microsoft.FeatureManagement; using System.Collections.Generic; using System.Linq; @@ -49,14 +50,14 @@ public GetPublicProviderByIdOrSlugQueryHandler(IProviderRepository providerRepos if (provider is null) { - return Result.Failure(Error.NotFound("Prestador não encontrado.")); + return Result.Failure(Error.NotFound(ProviderErrors.ProviderNotFound)); } // Validação adicional: Apenas prestadores ativos devem ser consultados publicamente // Se estiver suspenso ou rejeitado, retornamos NotFound por segurança/privacidade if (provider.Status != EProviderStatus.Active) { - return Result.Failure(Error.NotFound("Prestador não encontrado.")); + return Result.Failure(Error.NotFound(ProviderErrors.ProviderNotFound)); } var businessProfile = provider.BusinessProfile; @@ -103,9 +104,9 @@ public GetPublicProviderByIdOrSlugQueryHandler(IProviderRepository providerRepos return Result.Success(dto); } - private static IEnumerable ResolvePhoneNumbers(bool isPrivacyEnabled, BusinessProfile profile) + private static IEnumerable ResolvePhoneNumbers(bool shouldRedactContactInfo, BusinessProfile profile) { - if (isPrivacyEnabled || profile.ContactInfo is null) + if (shouldRedactContactInfo || profile.ContactInfo is null) return Array.Empty(); if (string.IsNullOrWhiteSpace(profile.ContactInfo.PhoneNumber)) diff --git a/src/Modules/Providers/Domain/Constants/ProviderErrors.cs b/src/Modules/Providers/Domain/Constants/ProviderErrors.cs new file mode 100644 index 000000000..853e584ef --- /dev/null +++ b/src/Modules/Providers/Domain/Constants/ProviderErrors.cs @@ -0,0 +1,6 @@ +namespace MeAjudaAi.Modules.Providers.Domain.Constants; + +public static class ProviderErrors +{ + public const string ProviderNotFound = "Prestador não encontrado."; +} diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index cad2ca19b..de25a88db 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -219,7 +219,7 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) .ReturnsAsync(true); - var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()) { IsAuthenticated = true }; // Act var result = await _handler.HandleAsync(query, CancellationToken.None); @@ -233,6 +233,42 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro result.Value.Services.Should().BeEmpty(); } + [Fact] + public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldReturnFullContactInfo() + { + // Arrange + var provider = ProviderBuilder.Create() + .WithBusinessProfile(new BusinessProfile( + "Restricted Legal", + new ContactInfo("privacy@test.com", "11999999999"), + new Address("Street", "1", "Neighborhood", "City", "ST", "00000-000", "Country"), + "Restricted Fantasy", + "Description")) + .Build(); + + SetProviderStatus(provider, EProviderStatus.Active); + + _providerRepositoryMock + .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) + .ReturnsAsync(provider); + + _featureManagerMock + .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) + .ReturnsAsync(false); + + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()) { IsAuthenticated = true }; + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(provider.Id); + result.Value.Email.Should().Be("privacy@test.com"); + result.Value.PhoneNumbers.Should().NotBeEmpty(); + } + private static void SetProviderStatus(Provider provider, EProviderStatus status) { var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index e39120d7b..990f3ad72 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -164,7 +164,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), + Slug = !string.IsNullOrWhiteSpace(slug) ? slug : SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), Description = description, City = city, State = state, diff --git a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs index 48b5bc9e5..1cbac30f9 100644 --- a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs @@ -114,9 +114,17 @@ public async Task> HandleAsync(RegisterCustomerCommand command, return Result.Failure(userResult.Error); } + if (userResult.Value is null) + { + logger.LogCritical("User returned null from success result for {Email}", maskedEmail); + return Result.Failure(Error.Internal("Falha crítica ao criar o usuário. Dados nulos retornados.")); + } + + var user = userResult.Value; + try { - await userRepository.AddAsync(userResult.Value!, cancellationToken); + await userRepository.AddAsync(user, cancellationToken); } catch (Exception ex) { @@ -127,37 +135,37 @@ public async Task> HandleAsync(RegisterCustomerCommand command, else { logger.LogError(ex, "Failed to persist customer {Email} ({Id}) to repository. Attempting Keycloak compensation.", - maskedEmail, userResult.Value!.Id); + maskedEmail, user.Id); } // Verifica se o usuário realmente não foi salvo no repositório antes da compensação // Usamos CancellationToken.None para garantir que a compensação ocorra mesmo se o request original foi cancelado - var persistenceCheck = await userRepository.GetByIdNoTrackingAsync(userResult.Value!.Id, CancellationToken.None); + var persistenceCheck = await userRepository.GetByIdNoTrackingAsync(user.Id, CancellationToken.None); if (persistenceCheck == null) { // Compensação: desativar o usuário criado no Keycloak para evitar usuário órfão "fantasma" que pode logar mas não tem dados locais try { - var compensationResult = await userDomainService.DeactivateUserInKeycloakAsync(userResult.Value.Id, CancellationToken.None); + var compensationResult = await userDomainService.DeactivateUserInKeycloakAsync(user.Id, CancellationToken.None); if (compensationResult.IsFailure) { logger.LogError("Compensation failed for user {UserId}: {Error}", userResult.Value.Id, compensationResult.Error); } else { - logger.LogInformation("Keycloak user {UserId} deactivated successfully as compensation.", userResult.Value.Id); + logger.LogInformation("Keycloak user {UserId} deactivated successfully as compensation.", user.Id); } } catch (Exception compensationEx) { logger.LogCritical(compensationEx, "CRITICAL: Failed to compensate Keycloak user {UserId} after repository failure. Manual cleanup required.", - userResult.Value.Id); + user.Id); } } else { - logger.LogWarning("Repository write failure reported but user {UserId} was found in DB. Skipping Keycloak compensation.", userResult.Value.Id); + logger.LogWarning("Repository write failure reported but user {UserId} was found in DB. Skipping Keycloak compensation.", user.Id); } if (ex is OperationCanceledException) @@ -166,8 +174,8 @@ public async Task> HandleAsync(RegisterCustomerCommand command, return Result.Failure(Error.Internal("Falha ao salvar o cadastro. Tente novamente mais tarde.")); } - logger.LogInformation("Customer registered successfully: {Email} ({Id})", maskedEmail, userResult.Value!.Id); + logger.LogInformation("Customer registered successfully: {Email} ({Id})", maskedEmail, user.Id); - return Result.Success(userResult.Value!.ToDto()); + return Result.Success(user.ToDto()); } } diff --git a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs index 353625d33..3e828f8d3 100644 --- a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs +++ b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs @@ -63,13 +63,14 @@ public async Task> CreateUserAsync( return Result.Failure(keycloakResult.Error); // Cria a entidade User local com o ID retornado pelo Keycloak - var userResult = User.Create(username, email, firstName, lastName, keycloakResult.Value!, phoneNumber); + var keycloakId = keycloakResult.Value!; + var userResult = User.Create(username, email, firstName, lastName, keycloakId, phoneNumber); if (userResult.IsFailure) { - var deactivationResult = await keycloakService.DeactivateUserAsync(keycloakResult.Value, CancellationToken.None); + var deactivationResult = await keycloakService.DeactivateUserAsync(keycloakId, CancellationToken.None); if (deactivationResult.IsFailure) { - logger.LogWarning("Failed to deactivate Keycloak user {KeycloakId} during compensation for local user creation failure. Error: {Error}", keycloakResult.Value, deactivationResult.Error.Message); + logger.LogWarning("Failed to deactivate Keycloak user {KeycloakId} during compensation for local user creation failure. Error: {Error}", keycloakId, deactivationResult.Error.Message); // Silenciar falhas de compensação para evitar mascarar o erro de validação original } return Result.Failure(userResult.Error); diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index f16de251a..db09de8af 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -13,6 +13,42 @@ namespace MeAjudaAi.Modules.Users.Tests.Unit.API; [Trait("Layer", "API")] public class ExtensionsTests { + private static IConfiguration BuildTestConfiguration() + { + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" + }) + .Build(); + } + + [Fact] + public void AddUsersModule_WithNullServices_ShouldThrowArgumentNullException() + { + // Arrange + IServiceCollection services = null!; + var configuration = BuildTestConfiguration(); + + // Act & Assert + var act = () => services.AddUsersModule(configuration); + + act.Should().Throw().WithParameterName("services"); + } + + [Fact] + public void AddUsersModule_WithNullConfiguration_ShouldThrowArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + IConfiguration configuration = null!; + + // Act & Assert + var act = () => services.AddUsersModule(configuration); + + act.Should().Throw().WithParameterName("configuration"); + } + [Fact] public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() { @@ -51,11 +87,22 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - // Act & Assert - var act = () => services.AddUsersModule(configuration); - - act.Should().Throw() - .WithMessage("Connection for Users module not configured"); + var originalEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + try + { + // Força ambiente não teste/dev para testar o fallback de exception + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + + // Act & Assert + var act = () => services.AddUsersModule(configuration); + + act.Should().Throw() + .WithMessage("Connection for Users module not configured"); + } + finally + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnv); + } } [Fact] @@ -63,12 +110,7 @@ public void AddUsersModule_ShouldReturnSameServiceCollectionInstance() { // Arrange var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" - }) - .Build(); + var configuration = BuildTestConfiguration(); // Act var result = services.AddUsersModule(configuration); @@ -111,12 +153,7 @@ public void AddUsersModule_WithMinimalConfiguration_ShouldRegisterServices() { // Arrange var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" - }) - .Build(); + var configuration = BuildTestConfiguration(); // Act var result = services.AddUsersModule(configuration); From b3337f16868305385db7836f5217fd4916614787 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 13:47:10 -0300 Subject: [PATCH 13/24] feat: Add unit tests for GetPublicProviderByIdOrSlugQueryHandler, introduce SearchableProvider entity, and implement RegisterCustomerCommandHandler with related API extension tests. --- ...tPublicProviderByIdOrSlugQueryHandlerTests.cs | 16 ++++++++++------ .../Domain/Entities/SearchableProvider.cs | 16 +++++++++++++--- .../Commands/RegisterCustomerCommandHandler.cs | 2 +- .../Users/Tests/Unit/API/ExtensionsTests.cs | 2 +- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index de25a88db..45f20dc04 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -36,7 +36,7 @@ public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificati .WithVerificationStatus(EVerificationStatus.Verified) .Build(); - // Bypass domain transitions to set Active status directly for test + // Ignora transições de domínio para definir o status como Active diretamente no teste SetProviderStatus(provider, EProviderStatus.Active); _providerRepositoryMock @@ -105,7 +105,7 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize .Setup(x => x.GetBySlugAsync(normalizedSlug, It.IsAny())) .ReturnsAsync(provider); - // Query uses uppercase slug — handler must normalize before calling GetBySlugAsync + // Query usa slug em maiúsculas — o handler deve normalizar antes de chamar GetBySlugAsync var query = new GetPublicProviderByIdOrSlugQuery(upperSlug); // Act @@ -121,7 +121,7 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize [Fact] public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLookup() { - // Arrange — slug that happens to be a valid GUID format (Guid.TryParse returns true) + // Arrange — slug que possui um formato de GUID válido (Guid.TryParse retorna true) var slugGuid = Guid.NewGuid(); var slugValue = slugGuid.ToString().ToLowerInvariant(); // e.g. "3fa85f64-5717-4562-b3fc-2c963f66afa6" @@ -133,12 +133,12 @@ public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLook // Bypass domain transitions to set Active status directly for test SetProviderStatus(provider, EProviderStatus.Active); - // GetByIdAsync returns null (no provider with that ID) + // GetByIdAsync retorna null (nenhum provedor com esse ID) _providerRepositoryMock .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((Provider?)null); - // Fallback to slug lookup returns the provider + // Fallback para a busca por slug retorna o provedor _providerRepositoryMock .Setup(x => x.GetBySlugAsync(slugValue, It.IsAny())) .ReturnsAsync(provider); @@ -160,7 +160,7 @@ public async Task HandleAsync_WhenProviderIsNotActive_ShouldReturnNotFound() // Arrange var provider = ProviderBuilder.Create() .Build(); - // Default builder status is PendingBasicInfo (not Active) + // O status padrão do builder é PendingBasicInfo (não Active) _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) @@ -246,6 +246,9 @@ public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldRetu "Description")) .Build(); + var expectedServiceId = Guid.NewGuid(); + provider.AddService(expectedServiceId, "Known Service"); + SetProviderStatus(provider, EProviderStatus.Active); _providerRepositoryMock @@ -267,6 +270,7 @@ public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldRetu result.Value!.Id.Should().Be(provider.Id); result.Value.Email.Should().Be("privacy@test.com"); result.Value.PhoneNumbers.Should().NotBeEmpty(); + result.Value.Services.Should().Contain(s => s.ServiceId == expectedServiceId); } private static void SetProviderStatus(Provider provider, EProviderStatus status) diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 990f3ad72..8a7109cb4 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -110,7 +110,7 @@ public static SearchableProvider Create( { if (string.IsNullOrWhiteSpace(name)) { - throw new ArgumentException("Provider name cannot be empty.", nameof(name)); + throw new ArgumentException("O nome do provedor não pode ficar vazio.", nameof(name)); } slug = NormalizeAndValidateSlug(slug); @@ -157,6 +157,16 @@ internal static SearchableProvider Reconstitute( string? city = null, string? state = null) { + string normalizedSlug; + try + { + normalizedSlug = NormalizeAndValidateSlug(slug); + } + catch (ArgumentException) + { + normalizedSlug = SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]); + } + var searchableProvider = new SearchableProvider( new SearchableProviderId(id), providerId, @@ -164,7 +174,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = !string.IsNullOrWhiteSpace(slug) ? slug : SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), + Slug = normalizedSlug, Description = description, City = city, State = state, @@ -184,7 +194,7 @@ public void UpdateBasicInfo(string name, string slug, string? description, strin { if (string.IsNullOrWhiteSpace(name)) { - throw new ArgumentException("Provider name cannot be empty.", nameof(name)); + throw new ArgumentException("O nome do provedor não pode ficar vazio.", nameof(name)); } slug = NormalizeAndValidateSlug(slug); diff --git a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs index 1cbac30f9..647fcd3ef 100644 --- a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs @@ -149,7 +149,7 @@ public async Task> HandleAsync(RegisterCustomerCommand command, var compensationResult = await userDomainService.DeactivateUserInKeycloakAsync(user.Id, CancellationToken.None); if (compensationResult.IsFailure) { - logger.LogError("Compensation failed for user {UserId}: {Error}", userResult.Value.Id, compensationResult.Error); + logger.LogError("Compensation failed for user {UserId}: {Error}", user.Id, compensationResult.Error); } else { diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index db09de8af..92dbd65ab 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -8,7 +8,7 @@ namespace MeAjudaAi.Modules.Users.Tests.Unit.API; /// Testes de integração dos métodos de extensão do módulo Users /// Foca em cenários de integração e configuração completa /// -[Trait("Category", "Integration")] +[Trait("Category", "Unit")] [Trait("Module", "Users")] [Trait("Layer", "API")] public class ExtensionsTests From 76d4ae91e1727f5b382cace4254f411561f9cac2 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 13:53:20 -0300 Subject: [PATCH 14/24] feat: Add slug field to Provider and SearchableProvider entities. --- ...20260318164815_AddProviderSlug.Designer.cs | 429 ++++++++++++++++++ .../20260318164815_AddProviderSlug.cs | 87 ++++ .../ProvidersDbContextModelSnapshot.cs | 17 +- ...4839_AddSearchableProviderSlug.Designer.cs | 136 ++++++ ...0260318164839_AddSearchableProviderSlug.cs | 22 + 5 files changed, 686 insertions(+), 5 deletions(-) create mode 100644 src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.Designer.cs create mode 100644 src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.cs create mode 100644 src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.Designer.cs create mode 100644 src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.cs diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.Designer.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.Designer.cs new file mode 100644 index 000000000..7257ba8f7 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.Designer.cs @@ -0,0 +1,429 @@ +// +using System; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ProvidersDbContext))] + [Migration("20260318164815_AddProviderSlug")] + partial class AddProviderSlug + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("providers") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("rejection_reason"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status"); + + b.Property("SuspensionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("suspension_reason"); + + b.Property("Tier") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Standard") + .HasColumnName("tier"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("verification_status"); + + b.HasKey("Id") + .HasName("pk_providers"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("ix_providers_is_deleted"); + + b.HasIndex("Name") + .HasDatabaseName("ix_providers_name"); + + b.HasIndex("Slug") + .HasDatabaseName("ix_providers_slug"); + + b.HasIndex("Status") + .HasDatabaseName("ix_providers_status"); + + b.HasIndex("Type") + .HasDatabaseName("ix_providers_type"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_providers_user_id"); + + b.HasIndex("VerificationStatus") + .HasDatabaseName("ix_providers_verification_status"); + + b.ToTable("providers", "providers"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.ProviderService", b => + { + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("service_name"); + + b.HasKey("ProviderId", "ServiceId") + .HasName("pk_provider_services"); + + b.HasIndex("ServiceId") + .HasDatabaseName("ix_provider_services_service_id"); + + b.ToTable("provider_services", "providers"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.BusinessProfile", "BusinessProfile", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("FantasyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("fantasy_name"); + + b1.Property("LegalName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("legal_name"); + + b1.HasKey("ProviderId"); + + b1.ToTable("providers", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId") + .HasConstraintName("fk_providers_providers_id"); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Address", "PrimaryAddress", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b2.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("city"); + + b2.Property("Complement") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("complement"); + + b2.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("country"); + + b2.Property("Neighborhood") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("neighborhood"); + + b2.Property("Number") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("number"); + + b2.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("state"); + + b2.Property("Street") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("street"); + + b2.Property("ZipCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("zip_code"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId") + .HasConstraintName("fk_providers_providers_id"); + }); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.ContactInfo", "ContactInfo", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b2.Property("AdditionalPhoneNumbers") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_phone_numbers"); + + b2.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b2.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone_number"); + + b2.Property("Website") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("website"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId") + .HasConstraintName("fk_providers_providers_id"); + }); + + b1.Navigation("ContactInfo") + .IsRequired(); + + b1.Navigation("PrimaryAddress") + .IsRequired(); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Document", "Documents", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("DocumentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("document_type"); + + b1.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b1.Property("FileUrl") + .HasColumnType("text") + .HasColumnName("file_url"); + + b1.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_primary"); + + b1.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("number"); + + b1.HasKey("ProviderId", "Id") + .HasName("pk_document"); + + b1.HasIndex("ProviderId", "DocumentType") + .IsUnique() + .HasDatabaseName("ix_document_provider_id_document_type"); + + b1.ToTable("document", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId") + .HasConstraintName("fk_document_providers_provider_id"); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Qualification", "Qualifications", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("document_number"); + + b1.Property("ExpirationDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_date"); + + b1.Property("IssueDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("issue_date"); + + b1.Property("IssuingOrganization") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("issuing_organization"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b1.HasKey("ProviderId", "Id") + .HasName("pk_qualification"); + + b1.ToTable("qualification", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId") + .HasConstraintName("fk_qualification_providers_provider_id"); + }); + + b.Navigation("BusinessProfile") + .IsRequired(); + + b.Navigation("Documents"); + + b.Navigation("Qualifications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.ProviderService", b => + { + b.HasOne("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", "Provider") + .WithMany("Services") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_provider_services_providers_provider_id"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.Navigation("Services"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.cs new file mode 100644 index 000000000..16cb6d5fc --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddProviderSlug : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "slug", + schema: "providers", + table: "providers", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.AlterColumn( + name: "file_url", + schema: "providers", + table: "document", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(2048)", + oldMaxLength: 2048, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "file_name", + schema: "providers", + table: "document", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255, + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "ix_providers_slug", + schema: "providers", + table: "providers", + column: "slug"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_providers_slug", + schema: "providers", + table: "providers"); + + migrationBuilder.DropColumn( + name: "slug", + schema: "providers", + table: "providers"); + + migrationBuilder.AlterColumn( + name: "file_url", + schema: "providers", + table: "document", + type: "character varying(2048)", + maxLength: 2048, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "file_name", + schema: "providers", + table: "document", + type: "character varying(255)", + maxLength: 255, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs index 12aaba786..cb25f63d8 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("providers") - .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("ProductVersion", "10.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -52,6 +52,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(1000)") .HasColumnName("rejection_reason"); + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("slug"); + b.Property("Status") .IsRequired() .HasMaxLength(30) @@ -100,6 +106,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Name") .HasDatabaseName("ix_providers_name"); + b.HasIndex("Slug") + .HasDatabaseName("ix_providers_slug"); + b.HasIndex("Status") .HasDatabaseName("ix_providers_status"); @@ -302,13 +311,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("document_type"); b1.Property("FileName") - .HasMaxLength(255) - .HasColumnType("character varying(255)") + .HasColumnType("text") .HasColumnName("file_name"); b1.Property("FileUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)") + .HasColumnType("text") .HasColumnName("file_url"); b1.Property("IsPrimary") diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.Designer.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.Designer.cs new file mode 100644 index 000000000..996e03544 --- /dev/null +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(SearchProvidersDbContext))] + [Migration("20260318164839_AddSearchableProviderSlug")] + partial class AddSearchableProviderSlug + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("search_providers") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.SearchProviders.Domain.Entities.SearchableProvider", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("numeric(3,2)") + .HasColumnName("average_rating"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("city"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("Location") + .IsRequired() + .HasColumnType("geography(Point, 4326)") + .HasColumnName("location"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.PrimitiveCollection("ServiceIds") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("service_ids"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("slug"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("state"); + + b.Property("SubscriptionTier") + .HasColumnType("integer") + .HasColumnName("subscription_tier"); + + b.Property("TotalReviews") + .HasColumnType("integer") + .HasColumnName("total_reviews"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_searchable_providers"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_searchable_providers_is_active"); + + b.HasIndex("Location") + .HasDatabaseName("ix_searchable_providers_location"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist"); + + b.HasIndex("ProviderId") + .IsUnique() + .HasDatabaseName("ix_searchable_providers_provider_id"); + + b.HasIndex("ServiceIds") + .HasDatabaseName("ix_searchable_providers_service_ids"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("ServiceIds"), "gin"); + + b.HasIndex("SubscriptionTier") + .HasDatabaseName("ix_searchable_providers_subscription_tier"); + + b.HasIndex("IsActive", "SubscriptionTier", "AverageRating") + .HasDatabaseName("ix_searchable_providers_search_ranking"); + + b.ToTable("searchable_providers", "search_providers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.cs new file mode 100644 index 000000000..f27041e4c --- /dev/null +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSearchableProviderSlug : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} From 0dea38861f3fd37157689f62a464c21de013a64f Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 14:00:22 -0300 Subject: [PATCH 15/24] feat: Introduce `SearchableProvider` entity and enhance public provider retrieval by ID or slug with privacy controls. --- ...tPublicProviderByIdOrSlugQueryHandlerTests.cs | 2 +- .../Domain/Entities/SearchableProvider.cs | 16 ++++++++-------- .../Users/Tests/Unit/API/ExtensionsTests.cs | 11 ++++++++--- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index 45f20dc04..6d67d9dd9 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -183,7 +183,7 @@ public async Task HandleAsync_WhenProviderNotFound_ShouldReturnNotFound() var providerId = Guid.NewGuid(); _providerRepositoryMock - .Setup(x => x.GetByIdAsync(providerId, It.IsAny())) + .Setup(x => x.GetByIdAsync(new ProviderId(providerId), It.IsAny())) .ReturnsAsync((Provider?)null); var query = new GetPublicProviderByIdOrSlugQuery(providerId.ToString()); diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 8a7109cb4..7809fbde8 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -157,12 +157,7 @@ internal static SearchableProvider Reconstitute( string? city = null, string? state = null) { - string normalizedSlug; - try - { - normalizedSlug = NormalizeAndValidateSlug(slug); - } - catch (ArgumentException) + if (!TryNormalizeSlug(slug, out var normalizedSlug)) { normalizedSlug = SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]); } @@ -288,12 +283,17 @@ public double CalculateDistanceToInKm(GeoPoint targetLocation) private static string NormalizeAndValidateSlug(string? slug) { - var normalized = SlugHelper.Generate(slug ?? string.Empty); - if (string.IsNullOrWhiteSpace(normalized)) + if (!TryNormalizeSlug(slug, out var normalized)) { throw new ArgumentException("O identificador do provedor não pode estar vazio.", nameof(slug)); } return normalized; } + + private static bool TryNormalizeSlug(string? slug, out string normalizedSlug) + { + normalizedSlug = SlugHelper.Generate(slug ?? string.Empty); + return !string.IsNullOrWhiteSpace(normalizedSlug); + } } diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 92dbd65ab..3a749591a 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -5,12 +5,13 @@ namespace MeAjudaAi.Modules.Users.Tests.Unit.API; /// -/// Testes de integração dos métodos de extensão do módulo Users +/// Testes unitários dos métodos de extensão do módulo Users /// Foca em cenários de integração e configuração completa /// [Trait("Category", "Unit")] [Trait("Module", "Users")] [Trait("Layer", "API")] +[Collection("NonParallel Environment Tests")] public class ExtensionsTests { private static IConfiguration BuildTestConfiguration() @@ -87,11 +88,14 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - var originalEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var originalAspNetCoreEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var originalDotnetEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + try { // Força ambiente não teste/dev para testar o fallback de exception Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Production"); // Act & Assert var act = () => services.AddUsersModule(configuration); @@ -101,7 +105,8 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc } finally { - Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnv); + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalAspNetCoreEnv); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", originalDotnetEnv); } } From 22aecb75bc5e6d9daa857beac393cc58787f3537 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 14:08:31 -0300 Subject: [PATCH 16/24] test: add unit tests for GetPublicProviderByIdOrSlugQueryHandler covering active/inactive providers, ID/slug queries, and privacy feature flag scenarios. --- .../Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index 6d67d9dd9..b70040e70 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -270,7 +270,7 @@ public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldRetu result.Value!.Id.Should().Be(provider.Id); result.Value.Email.Should().Be("privacy@test.com"); result.Value.PhoneNumbers.Should().NotBeEmpty(); - result.Value.Services.Should().Contain(s => s.ServiceId == expectedServiceId); + result.Value.Services.Should().Contain("Known Service"); } private static void SetProviderStatus(Provider provider, EProviderStatus status) From 0830eaa137a6c8836960d937df13c46aa2dd4516 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 18:14:12 -0300 Subject: [PATCH 17/24] feat: Introduce SearchableProvider entity and add comprehensive unit tests for public provider retrieval, including privacy feature handling. --- .../GetPublicProviderByIdOrSlugQueryHandlerTests.cs | 9 ++++++--- .../Domain/Entities/SearchableProvider.cs | 2 +- .../Unit/Domain/Entities/SearchableProviderTests.cs | 6 +++--- src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs | 5 ++++- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index b70040e70..39c6c5d62 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -65,7 +65,7 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() .WithVerificationStatus(EVerificationStatus.Verified) .Build(); - // Bypass domain transitions to set Active status directly for test + // Ignora transições de domínio para definir o status Active diretamente para o teste SetProviderStatus(provider, EProviderStatus.Active); var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); @@ -95,7 +95,7 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize .WithVerificationStatus(EVerificationStatus.Verified) .Build(); - // Bypass domain transitions to set Active status directly for test + // Ignora transições de domínio para definir o status Active diretamente para o teste SetProviderStatus(provider, EProviderStatus.Active); var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); @@ -130,7 +130,7 @@ public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLook .WithVerificationStatus(EVerificationStatus.Verified) .Build(); - // Bypass domain transitions to set Active status directly for test + // Ignora transições de domínio para definir o status Active diretamente para o teste SetProviderStatus(provider, EProviderStatus.Active); // GetByIdAsync retorna null (nenhum provedor com esse ID) @@ -152,6 +152,9 @@ public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLook result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNull(); result.Value!.Id.Should().Be(provider.Id); + + _providerRepositoryMock.Verify(x => x.GetByIdAsync(It.Is(p => p.Value == slugGuid), It.IsAny()), Times.Once); + _providerRepositoryMock.Verify(x => x.GetBySlugAsync(slugValue, It.IsAny()), Times.Once); } [Fact] diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 7809fbde8..ab95fefb9 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -285,7 +285,7 @@ private static string NormalizeAndValidateSlug(string? slug) { if (!TryNormalizeSlug(slug, out var normalized)) { - throw new ArgumentException("O identificador do provedor não pode estar vazio.", nameof(slug)); + throw new ArgumentException("O identificador do provedor não pode estar vazio nem em formato inválido.", nameof(slug)); } return normalized; diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index deb375462..c3d22b2bf 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -60,7 +60,7 @@ public void Create_WithEmptyName_ShouldThrowArgumentException() // Assert act.Should().Throw() - .WithMessage("*Provider name cannot be empty*"); + .WithMessage("*O nome do provedor não pode ficar vazio*"); } [Fact] @@ -89,7 +89,7 @@ public void Create_WithEmptySlug_ShouldThrowArgumentException() // Assert act.Should().Throw() - .WithMessage("*O identificador do provedor não pode estar vazio.*"); + .WithMessage("*O identificador do provedor não pode estar vazio nem em formato inválido*"); } [Fact] @@ -131,7 +131,7 @@ public void UpdateBasicInfo_WithEmptySlug_ShouldThrowArgumentException() // Assert act.Should().Throw() - .WithMessage("*O identificador do provedor não pode estar vazio.*"); + .WithMessage("*O identificador do provedor não pode estar vazio nem em formato inválido*"); } [Fact] diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 3a749591a..00f4dc818 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -6,7 +6,7 @@ namespace MeAjudaAi.Modules.Users.Tests.Unit.API; /// /// Testes unitários dos métodos de extensão do módulo Users -/// Foca em cenários de integração e configuração completa +/// Foca em cenários unitários e configuração completa /// [Trait("Category", "Unit")] [Trait("Module", "Users")] @@ -227,3 +227,6 @@ public void AddUsersModule_WithCompleteConfiguration_ShouldBuildServiceProvider( Assert.NotNull(serviceProvider); } } + +[CollectionDefinition("NonParallel Environment Tests", DisableParallelization = true)] +public class NonParallelEnvironmentTestsCollection { } From 6dc9cb0d8f9d50583fb5128f3232ab13d6bd64ad Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 18:37:50 -0300 Subject: [PATCH 18/24] test: add ProviderBuilder and unit tests for GetPublicProviderByIdOrSlugQueryHandler and SearchableProvider entity mapping --- .../Tests/Builders/ProviderBuilder.cs | 18 +++++ ...blicProviderByIdOrSlugQueryHandlerTests.cs | 67 ++++++++++++------- .../Entities/SearchableProviderTests.cs | 15 +++++ 3 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs index d87d5ab01..0aa3d00da 100644 --- a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs +++ b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs @@ -16,6 +16,7 @@ public class ProviderBuilder : BaseBuilder private ProviderId? _providerId; private EVerificationStatus? _verificationStatus; private EProviderTier? _tier; + private EProviderStatus? _status; private readonly List _documents = new(); private readonly List _qualifications = new(); @@ -80,6 +81,17 @@ public ProviderBuilder() prop.SetValue(provider, _tier.Value); } + // Define status se especificado + if (_status.HasValue) + { + var prop = typeof(Provider).GetProperty(nameof(Provider.Status)); + if (prop == null) + { + throw new InvalidOperationException($"Property '{nameof(Provider.Status)}' was not found on class {nameof(Provider)}."); + } + prop.SetValue(provider, _status.Value); + } + return provider; }); } @@ -90,6 +102,12 @@ public ProviderBuilder WithTier(EProviderTier tier) return this; } + public ProviderBuilder WithStatus(EProviderStatus status) + { + _status = status; + return this; + } + public ProviderBuilder WithUserId(Guid userId) { _userId = userId; diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index 39c6c5d62..82354d4b8 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -34,11 +34,10 @@ public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificati var provider = ProviderBuilder.Create() .WithType(EProviderType.Individual) .WithVerificationStatus(EVerificationStatus.Verified) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); - // Ignora transições de domínio para definir o status como Active diretamente no teste - SetProviderStatus(provider, EProviderStatus.Active); - _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) .ReturnsAsync(provider); @@ -63,11 +62,10 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() var provider = ProviderBuilder.Create() .WithType(EProviderType.Individual) .WithVerificationStatus(EVerificationStatus.Verified) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); - // Ignora transições de domínio para definir o status Active diretamente para o teste - SetProviderStatus(provider, EProviderStatus.Active); - var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); _providerRepositoryMock @@ -93,11 +91,10 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize var provider = ProviderBuilder.Create() .WithType(EProviderType.Individual) .WithVerificationStatus(EVerificationStatus.Verified) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); - // Ignora transições de domínio para definir o status Active diretamente para o teste - SetProviderStatus(provider, EProviderStatus.Active); - var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); var upperSlug = provider.Slug.ToUpperInvariant(); @@ -118,6 +115,37 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize result.Value.Slug.Should().Be(provider.Slug); } + [Fact] + public async Task HandleAsync_WhenProviderQueriedBySlugWithLeadingTrailingSpaces_ShouldNormalizeAndReturnDto() + { + // Arrange + var provider = ProviderBuilder.Create() + .WithType(EProviderType.Individual) + .WithVerificationStatus(EVerificationStatus.Verified) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) + .Build(); + + var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); + var dirtySlug = $" {provider.Slug.ToUpperInvariant()} "; + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(normalizedSlug, It.IsAny())) + .ReturnsAsync(provider); + + // Query usa slug com espaços em claro e em maiúsculas — o handler deve normalizar (Trim e ToLower) antes de buscar + var query = new GetPublicProviderByIdOrSlugQuery(dirtySlug); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(provider.Id); + result.Value.Slug.Should().Be(provider.Slug); + } + [Fact] public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLookup() { @@ -128,11 +156,10 @@ public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLook var provider = ProviderBuilder.Create() .WithType(EProviderType.Individual) .WithVerificationStatus(EVerificationStatus.Verified) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); - // Ignora transições de domínio para definir o status Active diretamente para o teste - SetProviderStatus(provider, EProviderStatus.Active); - // GetByIdAsync retorna null (nenhum provedor com esse ID) _providerRepositoryMock .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) @@ -209,11 +236,10 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro new Address("Street", "1", "Neighborhood", "City", "ST", "00000-000", "Country"), "Restricted Fantasy", "Description")) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); - // Bypass domain transitions to set Active status directly for test - SetProviderStatus(provider, EProviderStatus.Active); - _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) .ReturnsAsync(provider); @@ -247,12 +273,12 @@ public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldRetu new Address("Street", "1", "Neighborhood", "City", "ST", "00000-000", "Country"), "Restricted Fantasy", "Description")) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); var expectedServiceId = Guid.NewGuid(); provider.AddService(expectedServiceId, "Known Service"); - - SetProviderStatus(provider, EProviderStatus.Active); _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) @@ -275,11 +301,4 @@ public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldRetu result.Value.PhoneNumbers.Should().NotBeEmpty(); result.Value.Services.Should().Contain("Known Service"); } - - private static void SetProviderStatus(Provider provider, EProviderStatus status) - { - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, status); - } } diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index c3d22b2bf..dd5f76d38 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -106,6 +106,21 @@ public void Create_WithWhitespaceSlug_ShouldThrowArgumentException() act.Should().Throw(); } + [Fact] + public void Create_WithSpecialCharactersSlug_ShouldThrowArgumentException() + { + // Arrange + var providerId = Guid.NewGuid(); + var location = new GeoPoint(-23.5505, -46.6333); + + // Act + var act = () => SearchableProvider.Create(providerId, "Valid Name", "!!!", location); + + // Assert + act.Should().Throw() + .WithMessage("*O identificador do provedor não pode estar vazio nem em formato inválido*"); + } + [Fact] public void Create_WithUppercaseSlug_ShouldNormalizeToLowercase() { From 5abf6211dc1f730188375e2317437fbd11ffd104 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 09:36:46 -0300 Subject: [PATCH 19/24] test: Add unit and end-to-end tests for Users, Providers, and SearchProviders modules, including cross-module workflows. --- .../Tests/Builders/ProviderBuilder.cs | 24 ++++----- ...blicProviderByIdOrSlugQueryHandlerTests.cs | 49 +++++++++++++++++++ .../Users/Tests/Unit/API/ExtensionsTests.cs | 3 +- ...oviderServiceCatalogSearchWorkflowTests.cs | 24 ++++++--- .../SearchProvidersEndToEndTests.cs | 6 ++- 5 files changed, 82 insertions(+), 24 deletions(-) diff --git a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs index 0aa3d00da..cc372879c 100644 --- a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs +++ b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs @@ -73,23 +73,13 @@ public ProviderBuilder() // Define tier se especificado if (_tier.HasValue) { - var prop = typeof(Provider).GetProperty(nameof(Provider.Tier)); - if (prop == null) - { - throw new InvalidOperationException($"Property '{nameof(Provider.Tier)}' was not found on class {nameof(Provider)}."); - } - prop.SetValue(provider, _tier.Value); + SetPropertyValue(provider, nameof(Provider.Tier), _tier.Value); } // Define status se especificado if (_status.HasValue) { - var prop = typeof(Provider).GetProperty(nameof(Provider.Status)); - if (prop == null) - { - throw new InvalidOperationException($"Property '{nameof(Provider.Status)}' was not found on class {nameof(Provider)}."); - } - prop.SetValue(provider, _status.Value); + SetPropertyValue(provider, nameof(Provider.Status), _status.Value); } return provider; @@ -234,4 +224,14 @@ private static BusinessProfile CreateDefaultBusinessProfile(Faker faker) contactInfo: contactInfo, primaryAddress: address); } + + private static void SetPropertyValue(Provider provider, string propertyName, object value) + { + var prop = typeof(Provider).GetProperty(propertyName); + if (prop == null) + { + throw new InvalidOperationException($"Property '{propertyName}' was not found on class {nameof(Provider)}."); + } + prop.SetValue(provider, value); + } } diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index 82354d4b8..3ef8d2427 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -24,6 +24,11 @@ public GetPublicProviderByIdOrSlugQueryHandlerTests() { _providerRepositoryMock = new Mock(); _featureManagerMock = new Mock(); + + _featureManagerMock + .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) + .ReturnsAsync(false); + _handler = new GetPublicProviderByIdOrSlugQueryHandler(_providerRepositoryMock.Object, _featureManagerMock.Object); } @@ -225,6 +230,48 @@ public async Task HandleAsync_WhenProviderNotFound_ShouldReturnNotFound() result.IsSuccess.Should().BeFalse(); result.Error.StatusCode.Should().Be(404); } + + [Fact] + public async Task HandleAsync_WhenProviderIsNotActive_BySlug_ShouldReturnNotFound() + { + // Arrange + var provider = ProviderBuilder.Create() + .Build(); + // O status padrão do builder é PendingBasicInfo (não Active) + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(provider.Slug, It.IsAny())) + .ReturnsAsync(provider); + + var query = new GetPublicProviderByIdOrSlugQuery(provider.Slug); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.StatusCode.Should().Be(404); + } + + [Fact] + public async Task HandleAsync_WhenProviderNotFound_BySlug_ShouldReturnNotFound() + { + // Arrange + var slug = "non-existent-slug"; + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(slug, It.IsAny())) + .ReturnsAsync((Provider?)null); + + var query = new GetPublicProviderByIdOrSlugQuery(slug); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.StatusCode.Should().Be(404); + } [Fact] public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedProvider() { @@ -240,6 +287,8 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro .WithStatus(EProviderStatus.Active) .Build(); + provider.AddService(Guid.NewGuid(), "Restricted Service"); + _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) .ReturnsAsync(provider); diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 00f4dc818..dc7baccf0 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -100,8 +100,7 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc // Act & Assert var act = () => services.AddUsersModule(configuration); - act.Should().Throw() - .WithMessage("Connection for Users module not configured"); + act.Should().Throw(); } finally { diff --git a/tests/MeAjudaAi.E2E.Tests/CrossModule/ProviderServiceCatalogSearchWorkflowTests.cs b/tests/MeAjudaAi.E2E.Tests/CrossModule/ProviderServiceCatalogSearchWorkflowTests.cs index e51dcec08..9be7b3947 100644 --- a/tests/MeAjudaAi.E2E.Tests/CrossModule/ProviderServiceCatalogSearchWorkflowTests.cs +++ b/tests/MeAjudaAi.E2E.Tests/CrossModule/ProviderServiceCatalogSearchWorkflowTests.cs @@ -190,11 +190,12 @@ await _fixture.WithServiceScopeAsync(async sp => var sql = @" INSERT INTO search_providers.searchable_providers - (id, provider_id, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) + (id, provider_id, slug, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) VALUES - (@Id, @ProviderId, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) + (@Id, @ProviderId, @Slug, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) ON CONFLICT (provider_id) DO UPDATE SET + slug = EXCLUDED.slug, service_ids = EXCLUDED.service_ids, updated_at = CURRENT_TIMESTAMP"; @@ -202,6 +203,7 @@ DO UPDATE SET { Id = Guid.NewGuid(), ProviderId = providerId, + Slug = providerRequest.Name!.ToLowerInvariant().Replace(" ", "-").Replace("_", "-"), Name = providerRequest.Name, Latitude = latitude, Longitude = longitude, @@ -436,19 +438,22 @@ await _fixture.WithServiceScopeAsync(async sp => var sql = @" INSERT INTO search_providers.searchable_providers - (id, provider_id, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) + (id, provider_id, slug, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) VALUES - (@Id, @ProviderId, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) + (@Id, @ProviderId, @Slug, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) ON CONFLICT (provider_id) DO UPDATE SET + slug = EXCLUDED.slug, service_ids = EXCLUDED.service_ids, updated_at = CURRENT_TIMESTAMP"; + var name = $"MultiService_{uniqueId}"; await dapper.ExecuteAsync(sql, new { Id = Guid.NewGuid(), ProviderId = providerId1, - Name = $"MultiService_{uniqueId}", + Slug = name.ToLowerInvariant().Replace(" ", "-").Replace("_", "-"), + Name = name, Latitude = -23.550520, Longitude = -46.633308, AvgRating = 0.0m, @@ -512,19 +517,22 @@ await _fixture.WithServiceScopeAsync(async sp => var sql = @" INSERT INTO search_providers.searchable_providers - (id, provider_id, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) + (id, provider_id, slug, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) VALUES - (@Id, @ProviderId, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) + (@Id, @ProviderId, @Slug, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) ON CONFLICT (provider_id) DO UPDATE SET + slug = EXCLUDED.slug, service_ids = EXCLUDED.service_ids, updated_at = CURRENT_TIMESTAMP"; + var name = $"SingleService_{uniqueId}"; await dapper.ExecuteAsync(sql, new { Id = Guid.NewGuid(), ProviderId = providerId2, - Name = $"SingleService_{uniqueId}", + Slug = name.ToLowerInvariant().Replace(" ", "-").Replace("_", "-"), + Name = name, Latitude = -23.551000, Longitude = -46.634000, AvgRating = 0.0m, diff --git a/tests/MeAjudaAi.E2E.Tests/Modules/SearchProviders/SearchProvidersEndToEndTests.cs b/tests/MeAjudaAi.E2E.Tests/Modules/SearchProviders/SearchProvidersEndToEndTests.cs index bc56e6f49..6bb3ff35d 100644 --- a/tests/MeAjudaAi.E2E.Tests/Modules/SearchProviders/SearchProvidersEndToEndTests.cs +++ b/tests/MeAjudaAi.E2E.Tests/Modules/SearchProviders/SearchProvidersEndToEndTests.cs @@ -461,11 +461,12 @@ await _fixture.WithServiceScopeAsync(async sp => // We need to ensure we set the SRID to 4326 for the geography column var sql = @" INSERT INTO search_providers.searchable_providers - (id, provider_id, name, description, city, state, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at, updated_at) + (id, provider_id, slug, name, description, city, state, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at, updated_at) VALUES - (@Id, @ProviderId, @Name, @Description, @City, @State, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt, @UpdatedAt) + (@Id, @ProviderId, @Slug, @Name, @Description, @City, @State, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt, @UpdatedAt) ON CONFLICT (provider_id) DO UPDATE SET + slug = EXCLUDED.slug, name = EXCLUDED.name, location = EXCLUDED.location, service_ids = EXCLUDED.service_ids, @@ -476,6 +477,7 @@ DO UPDATE SET { Id = Guid.NewGuid(), ProviderId = providerId, + Slug = name.ToLowerInvariant().Replace(" ", "-").Replace("_", "-"), Name = name, Description = $"Test Provider {name}", City = "São Paulo", From 85406d7ebe49a19c19a97ea3124e821732c80220 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 10:31:53 -0300 Subject: [PATCH 20/24] feat: add unit tests for SearchableProvider entity, API extensions, and a Provider test builder. --- .../Tests/Builders/ProviderBuilder.cs | 39 ++++++++++++------- .../Entities/SearchableProviderTests.cs | 2 + .../Users/Tests/Unit/API/ExtensionsTests.cs | 2 +- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs index cc372879c..5d1d4005d 100644 --- a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs +++ b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs @@ -71,15 +71,35 @@ public ProviderBuilder() } // Define tier se especificado - if (_tier.HasValue) + if (_tier.HasValue && _tier.Value != EProviderTier.Standard) { - SetPropertyValue(provider, nameof(Provider.Tier), _tier.Value); + provider.PromoteTier(_tier.Value, "test-builder"); } - // Define status se especificado - if (_status.HasValue) + // Define status se especificado seguindo a máquina de estados do domínio + if (_status.HasValue && _status.Value != EProviderStatus.PendingBasicInfo) { - SetPropertyValue(provider, nameof(Provider.Status), _status.Value); + switch (_status.Value) + { + case EProviderStatus.PendingDocumentVerification: + provider.CompleteBasicInfo("test-builder"); + break; + + case EProviderStatus.Active: + provider.CompleteBasicInfo("test-builder"); + provider.Activate("test-builder"); + break; + + case EProviderStatus.Suspended: + provider.CompleteBasicInfo("test-builder"); + provider.Activate("test-builder"); + provider.Suspend("Test suspension", "test-builder"); + break; + + case EProviderStatus.Rejected: + provider.Reject("Test rejection", "test-builder"); + break; + } } return provider; @@ -225,13 +245,4 @@ private static BusinessProfile CreateDefaultBusinessProfile(Faker faker) primaryAddress: address); } - private static void SetPropertyValue(Provider provider, string propertyName, object value) - { - var prop = typeof(Provider).GetProperty(propertyName); - if (prop == null) - { - throw new InvalidOperationException($"Property '{propertyName}' was not found on class {nameof(Provider)}."); - } - prop.SetValue(provider, value); - } } diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index dd5f76d38..ff0e3f0e7 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -46,6 +46,7 @@ public void Create_WithValidData_ShouldCreateSearchableProvider() provider.AverageRating.Should().Be(0); provider.TotalReviews.Should().Be(0); provider.ServiceIds.Should().BeEmpty(); + provider.Slug.Should().Be("test-provider"); } [Fact] @@ -181,6 +182,7 @@ public void UpdateBasicInfo_WithValidData_ShouldUpdateFields() provider.City.Should().Be(newCity); provider.State.Should().Be(newState); provider.UpdatedAt.Should().NotBeNull(); + provider.Slug.Should().Be("updated-provider"); } [Fact] diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index dc7baccf0..9d3871bd4 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -19,7 +19,7 @@ private static IConfiguration BuildTestConfiguration() return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" + ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;" }) .Build(); } From a49c3dca2fb32a7bef56188c59daba6b568ba95c Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 10:50:06 -0300 Subject: [PATCH 21/24] feat: add unit tests for the Users module API extensions and SearchProviders domain entity. --- .../Entities/SearchableProviderTests.cs | 3 ++- .../Users/Tests/Unit/API/ExtensionsTests.cs | 21 +++---------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index ff0e3f0e7..5fa468dcd 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -104,7 +104,8 @@ public void Create_WithWhitespaceSlug_ShouldThrowArgumentException() var act = () => SearchableProvider.Create(providerId, "Valid Name", " ", location); // Assert - act.Should().Throw(); + act.Should().Throw() + .WithMessage("*O identificador do provedor não pode estar vazio nem em formato inválido*"); } [Fact] diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 9d3871bd4..ce0211d21 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -88,25 +88,10 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - var originalAspNetCoreEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - var originalDotnetEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + // Act & Assert + var act = () => services.AddUsersModule(configuration); - try - { - // Força ambiente não teste/dev para testar o fallback de exception - Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); - Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Production"); - - // Act & Assert - var act = () => services.AddUsersModule(configuration); - - act.Should().Throw(); - } - finally - { - Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalAspNetCoreEnv); - Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", originalDotnetEnv); - } + act.Should().Throw(); } [Fact] From f5199f2f9f819d280f41706be851c010476783e4 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 11:08:14 -0300 Subject: [PATCH 22/24] test(Users/API): add unit tests for AddUsersModule extension method --- .../Users/Tests/Unit/API/ExtensionsTests.cs | 59 ++++++++++--------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index ce0211d21..8bc4f91ba 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -1,6 +1,9 @@ using MeAjudaAi.Modules.Users.API; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] namespace MeAjudaAi.Modules.Users.Tests.Unit.API; @@ -11,10 +14,9 @@ namespace MeAjudaAi.Modules.Users.Tests.Unit.API; [Trait("Category", "Unit")] [Trait("Module", "Users")] [Trait("Layer", "API")] -[Collection("NonParallel Environment Tests")] public class ExtensionsTests { - private static IConfiguration BuildTestConfiguration() + private static IConfiguration BuildTestConfiguration_Minimal() { return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -29,7 +31,7 @@ public void AddUsersModule_WithNullServices_ShouldThrowArgumentNullException() { // Arrange IServiceCollection services = null!; - var configuration = BuildTestConfiguration(); + var configuration = BuildTestConfiguration_Minimal(); // Act & Assert var act = () => services.AddUsersModule(configuration); @@ -50,12 +52,9 @@ public void AddUsersModule_WithNullConfiguration_ShouldThrowArgumentNullExceptio act.Should().Throw().WithParameterName("configuration"); } - [Fact] - public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() + private static IConfiguration BuildTestConfiguration_Full() { - // Arrange - var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() + return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;User Id=test;Password=test;", @@ -65,6 +64,14 @@ public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() ["Keycloak:ClientSecret"] = "test-secret" }) .Build(); + } + + [Fact] + public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() + { + // Arrange + var services = new ServiceCollection(); + var configuration = BuildTestConfiguration_Full(); // Act var result = services.AddUsersModule(configuration); @@ -88,10 +95,20 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - // Act & Assert - var act = () => services.AddUsersModule(configuration); - - act.Should().Throw(); + var originalEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + try + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + + // Act & Assert + var act = () => services.AddUsersModule(configuration); + + act.Should().Throw(); + } + finally + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnvironment); + } } [Fact] @@ -99,7 +116,7 @@ public void AddUsersModule_ShouldReturnSameServiceCollectionInstance() { // Arrange var services = new ServiceCollection(); - var configuration = BuildTestConfiguration(); + var configuration = BuildTestConfiguration_Minimal(); // Act var result = services.AddUsersModule(configuration); @@ -113,16 +130,7 @@ public void AddUsersModule_ShouldConfigureServicesForDependencyInjection() { // Arrange var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;User Id=test;Password=test;", - ["Keycloak:BaseUrl"] = "http://localhost:8080", - ["Keycloak:Realm"] = "test-realm", - ["Keycloak:ClientId"] = "test-client", - ["Keycloak:ClientSecret"] = "test-secret" - }) - .Build(); + var configuration = BuildTestConfiguration_Full(); // Act services.AddUsersModule(configuration); @@ -142,7 +150,7 @@ public void AddUsersModule_WithMinimalConfiguration_ShouldRegisterServices() { // Arrange var services = new ServiceCollection(); - var configuration = BuildTestConfiguration(); + var configuration = BuildTestConfiguration_Minimal(); // Act var result = services.AddUsersModule(configuration); @@ -211,6 +219,3 @@ public void AddUsersModule_WithCompleteConfiguration_ShouldBuildServiceProvider( Assert.NotNull(serviceProvider); } } - -[CollectionDefinition("NonParallel Environment Tests", DisableParallelization = true)] -public class NonParallelEnvironmentTestsCollection { } From c26cd5387b4fe2c565343b2e154904a3cb1f1a7c Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 11:22:39 -0300 Subject: [PATCH 23/24] test: add unit tests for Users module API extension methods --- .../Users/Tests/Unit/API/ExtensionsTests.cs | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 8bc4f91ba..02669b12d 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -26,6 +26,20 @@ private static IConfiguration BuildTestConfiguration_Minimal() .Build(); } + private static IConfiguration BuildTestConfiguration_Full() + { + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;User Id=test;Password=test;", + ["Keycloak:BaseUrl"] = "http://localhost:8080", + ["Keycloak:Realm"] = "test-realm", + ["Keycloak:ClientId"] = "test-client", + ["Keycloak:ClientSecret"] = "test-secret" + }) + .Build(); + } + [Fact] public void AddUsersModule_WithNullServices_ShouldThrowArgumentNullException() { @@ -52,20 +66,6 @@ public void AddUsersModule_WithNullConfiguration_ShouldThrowArgumentNullExceptio act.Should().Throw().WithParameterName("configuration"); } - private static IConfiguration BuildTestConfiguration_Full() - { - return new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;User Id=test;Password=test;", - ["Keycloak:BaseUrl"] = "http://localhost:8080", - ["Keycloak:Realm"] = "test-realm", - ["Keycloak:ClientId"] = "test-client", - ["Keycloak:ClientSecret"] = "test-secret" - }) - .Build(); - } - [Fact] public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() { @@ -95,10 +95,12 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - var originalEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var originalAspNetCoreEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var originalDotNetEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); try { Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Production"); // Act & Assert var act = () => services.AddUsersModule(configuration); @@ -107,7 +109,8 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc } finally { - Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnvironment); + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalAspNetCoreEnv); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", originalDotNetEnv); } } From 6777beef42d21af36f07f263a7efcafac1d9cb1f Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 12:57:56 -0300 Subject: [PATCH 24/24] feat: Initialize the MeAjudaAi.Web.Provider Next.js application with a dashboard UI, core components, and project configuration, along with new roadmap documentation. --- docs/roadmap-current.md | 4 +- docs/roadmap-history.md | 2 +- src/Web/MeAjudaAi.Web.Provider/.swcrc | 30 + src/Web/MeAjudaAi.Web.Provider/index.d.ts | 6 + src/Web/MeAjudaAi.Web.Provider/next-env.d.ts | 5 + src/Web/MeAjudaAi.Web.Provider/next.config.js | 20 + src/Web/MeAjudaAi.Web.Provider/project.json | 9 + .../MeAjudaAi.Web.Provider/public/.gitkeep | 0 .../MeAjudaAi.Web.Provider/public/favicon.ico | Bin 0 -> 15086 bytes .../src/app/api/hello/route.ts | 3 + .../MeAjudaAi.Web.Provider/src/app/global.css | 44 + .../MeAjudaAi.Web.Provider/src/app/layout.tsx | 25 + .../src/app/page.module.css | 2 + .../MeAjudaAi.Web.Provider/src/app/page.tsx | 74 + .../src/components/ui/button.tsx | 54 + .../src/components/ui/card.tsx | 41 + src/Web/MeAjudaAi.Web.Provider/tsconfig.json | 39 + src/Web/nx.json | 32 +- src/Web/package-lock.json | 2208 +++++++++++++++-- src/Web/package.json | 3 + src/Web/tsconfig.base.json | 28 +- 21 files changed, 2381 insertions(+), 248 deletions(-) create mode 100644 src/Web/MeAjudaAi.Web.Provider/.swcrc create mode 100644 src/Web/MeAjudaAi.Web.Provider/index.d.ts create mode 100644 src/Web/MeAjudaAi.Web.Provider/next-env.d.ts create mode 100644 src/Web/MeAjudaAi.Web.Provider/next.config.js create mode 100644 src/Web/MeAjudaAi.Web.Provider/project.json create mode 100644 src/Web/MeAjudaAi.Web.Provider/public/.gitkeep create mode 100644 src/Web/MeAjudaAi.Web.Provider/public/favicon.ico create mode 100644 src/Web/MeAjudaAi.Web.Provider/src/app/api/hello/route.ts create mode 100644 src/Web/MeAjudaAi.Web.Provider/src/app/global.css create mode 100644 src/Web/MeAjudaAi.Web.Provider/src/app/layout.tsx create mode 100644 src/Web/MeAjudaAi.Web.Provider/src/app/page.module.css create mode 100644 src/Web/MeAjudaAi.Web.Provider/src/app/page.tsx create mode 100644 src/Web/MeAjudaAi.Web.Provider/src/components/ui/button.tsx create mode 100644 src/Web/MeAjudaAi.Web.Provider/src/components/ui/card.tsx create mode 100644 src/Web/MeAjudaAi.Web.Provider/tsconfig.json diff --git a/docs/roadmap-current.md b/docs/roadmap-current.md index 92b81e697..af8e644d1 100644 --- a/docs/roadmap-current.md +++ b/docs/roadmap-current.md @@ -589,8 +589,8 @@ Durante o processo de atualização automática de dependências pelo Dependabot --- -### ⏳ Sprint 8C - Provider Web App (React + NX) (19 Mar - 1 Abr 2026) -- Create `apps/provider-web`. +### 🔄 Sprint 8C - Provider Web App (React + NX) (19 Mar - 1 Abr 2026) +- **Scaffolding `apps/provider-web`** (Em andamento) - Implement registration steps (Upload, Dashboard). - **Slug Implementation**: Replace IDs with Slugs for SEO/Security. diff --git a/docs/roadmap-history.md b/docs/roadmap-history.md index 056b1701c..c5b033dcf 100644 --- a/docs/roadmap-history.md +++ b/docs/roadmap-history.md @@ -1589,7 +1589,7 @@ Get-ChildItem -Recurse -Include *.cs | Select-String "record " --- -### ΓÅ│ Sprint 8C - Provider Web App (React + NX) +### ▶️ Sprint 8C - Provider Web App (React + NX) - ACTIVE **Periodo Estimado**: 19 Mar - 1 Abr 2026 **Foco**: App de Administra├º├úo de Perfil para Prestadores diff --git a/src/Web/MeAjudaAi.Web.Provider/.swcrc b/src/Web/MeAjudaAi.Web.Provider/.swcrc new file mode 100644 index 000000000..e912f1855 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Provider/.swcrc @@ -0,0 +1,30 @@ +{ + "jsc": { + "target": "es2017", + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "decoratorMetadata": true, + "legacyDecorator": true + }, + "keepClassNames": true, + "externalHelpers": true, + "loose": true + }, + "module": { + "type": "commonjs" + }, + "sourceMaps": true, + "exclude": [ + "jest.config.[ct]s", + ".*\\.spec.tsx?$", + ".*\\.test.tsx?$", + "./src/jest-setup.ts$", + "./**/jest-setup.ts$", + ".*.js$", + ".*.d.ts$" + ] +} diff --git a/src/Web/MeAjudaAi.Web.Provider/index.d.ts b/src/Web/MeAjudaAi.Web.Provider/index.d.ts new file mode 100644 index 000000000..7ba08fa17 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Provider/index.d.ts @@ -0,0 +1,6 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +declare module '*.svg' { + const content: any; + export const ReactComponent: any; + export default content; +} diff --git a/src/Web/MeAjudaAi.Web.Provider/next-env.d.ts b/src/Web/MeAjudaAi.Web.Provider/next-env.d.ts new file mode 100644 index 000000000..1b3be0840 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Provider/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/Web/MeAjudaAi.Web.Provider/next.config.js b/src/Web/MeAjudaAi.Web.Provider/next.config.js new file mode 100644 index 000000000..6a591025e --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Provider/next.config.js @@ -0,0 +1,20 @@ +//@ts-check + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { composePlugins, withNx } = require('@nx/next'); + +/** + * @type {import('@nx/next/plugins/with-nx').WithNxOptions} + **/ +const nextConfig = { + // Use this to set Nx-specific options + // See: https://nx.dev/recipes/next/next-config-setup + nx: {}, +}; + +const plugins = [ + // Add more Next.js plugins to this list if needed. + withNx, +]; + +module.exports = composePlugins(...plugins)(nextConfig); diff --git a/src/Web/MeAjudaAi.Web.Provider/project.json b/src/Web/MeAjudaAi.Web.Provider/project.json new file mode 100644 index 000000000..befaecf75 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Provider/project.json @@ -0,0 +1,9 @@ +{ + "name": "MeAjudaAi.Web.Provider", + "$schema": "../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "MeAjudaAi.Web.Provider", + "projectType": "application", + "tags": [], + "// targets": "to see all targets run: nx show project MeAjudaAi.Web.Provider --web", + "targets": {} +} diff --git a/src/Web/MeAjudaAi.Web.Provider/public/.gitkeep b/src/Web/MeAjudaAi.Web.Provider/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/Web/MeAjudaAi.Web.Provider/public/favicon.ico b/src/Web/MeAjudaAi.Web.Provider/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..317ebcb2336e0833a22dddf0ab287849f26fda57 GIT binary patch literal 15086 zcmeI332;U^%p|z7g|#(P)qFEA@4f!_@qOK2 z_lJl}!lhL!VT_U|uN7%8B2iKH??xhDa;*`g{yjTFWHvXn;2s{4R7kH|pKGdy(7z!K zgftM+Ku7~24TLlh(!g)gz|foI94G^t2^IO$uvX$3(OR0<_5L2sB)lMAMy|+`xodJ{ z_Uh_1m)~h?a;2W{dmhM;u!YGo=)OdmId_B<%^V^{ovI@y`7^g1_V9G}*f# zNzAtvou}I!W1#{M^@ROc(BZ! z+F!!_aR&Px3_reO(EW+TwlW~tv*2zr?iP7(d~a~yA|@*a89IUke+c472NXM0wiX{- zl`UrZC^1XYyf%1u)-Y)jj9;MZ!SLfd2Hl?o|80Su%Z?To_=^g_Jt0oa#CT*tjx>BI z16wec&AOWNK<#i0Qd=1O$fymLRoUR*%;h@*@v7}wApDl^w*h}!sYq%kw+DKDY)@&A z@9$ULEB3qkR#85`lb8#WZw=@})#kQig9oqy^I$dj&k4jU&^2(M3q{n1AKeGUKPFbr z1^<)aH;VsG@J|B&l>UtU#Ejv3GIqERzYgL@UOAWtW<{p#zy`WyJgpCy8$c_e%wYJL zyGHRRx38)HyjU3y{-4z6)pzb>&Q1pR)B&u01F-|&Gx4EZWK$nkUkOI|(D4UHOXg_- zw{OBf!oWQUn)Pe(=f=nt=zkmdjpO^o8ZZ9o_|4tW1ni+Un9iCW47*-ut$KQOww!;u z`0q)$s6IZO!~9$e_P9X!hqLxu`fpcL|2f^I5d4*a@Dq28;@2271v_N+5HqYZ>x;&O z05*7JT)mUe&%S0@UD)@&8SmQrMtsDfZT;fkdA!r(S=}Oz>iP)w=W508=Rc#nNn7ym z1;42c|8($ALY8#a({%1#IXbWn9-Y|0eDY$_L&j{63?{?AH{);EzcqfydD$@-B`Y3<%IIj7S7rK_N}je^=dEk%JQ4c z!tBdTPE3Tse;oYF>cnrapWq*o)m47X1`~6@(!Y29#>-#8zm&LXrXa(3=7Z)ElaQqj z-#0JJy3Fi(C#Rx(`=VXtJ63E2_bZGCz+QRa{W0e2(m3sI?LOcUBx)~^YCqZ{XEPX)C>G>U4tfqeH8L(3|pQR*zbL1 zT9e~4Tb5p9_G}$y4t`i*4t_Mr9QYvL9C&Ah*}t`q*}S+VYh0M6GxTTSXI)hMpMpIq zD1ImYqJLzbj0}~EpE-aH#VCH_udYEW#`P2zYmi&xSPs_{n6tBj=MY|-XrA;SGA_>y zGtU$?HXm$gYj*!N)_nQ59%lQdXtQZS3*#PC-{iB_sm+ytD*7j`D*k(P&IH2GHT}Eh z5697eQECVIGQAUe#eU2I!yI&%0CP#>%6MWV z@zS!p@+Y1i1b^QuuEF*13CuB zu69dve5k7&Wgb+^s|UB08Dr3u`h@yM0NTj4h7MnHo-4@xmyr7(*4$rpPwsCDZ@2be zRz9V^GnV;;?^Lk%ynzq&K(Aix`mWmW`^152Hoy$CTYVehpD-S1-W^#k#{0^L`V6CN+E z!w+xte;2vu4AmVNEFUOBmrBL>6MK@!O2*N|2=d|Y;oN&A&qv=qKn73lDD zI(+oJAdgv>Yr}8(&@ZuAZE%XUXmX(U!N+Z_sjL<1vjy1R+1IeHt`79fnYdOL{$ci7 z%3f0A*;Zt@ED&Gjm|OFTYBDe%bbo*xXAQsFz+Q`fVBH!N2)kaxN8P$c>sp~QXnv>b zwq=W3&Mtmih7xkR$YA)1Yi?avHNR6C99!u6fh=cL|KQ&PwF!n@ud^n(HNIImHD!h87!i*t?G|p0o+eelJ?B@A64_9%SBhNaJ64EvKgD&%LjLCYnNfc; znj?%*p@*?dq#NqcQFmmX($wms@CSAr9#>hUR^=I+=0B)vvGX%T&#h$kmX*s=^M2E!@N9#m?LhMvz}YB+kd zG~mbP|D(;{s_#;hsKK9lbVK&Lo734x7SIFJ9V_}2$@q?zm^7?*XH94w5Qae{7zOMUF z^?%F%)c1Y)Q?Iy?I>knw*8gYW#ok|2gdS=YYZLiD=CW|Nj;n^x!=S#iJ#`~Ld79+xXpVmUK^B(xO_vO!btA9y7w3L3-0j-y4 z?M-V{%z;JI`bk7yFDcP}OcCd*{Q9S5$iGA7*E1@tfkyjAi!;wP^O71cZ^Ep)qrQ)N z#wqw0_HS;T7x3y|`P==i3hEwK%|>fZ)c&@kgKO1~5<5xBSk?iZV?KI6&i72H6S9A* z=U(*e)EqEs?Oc04)V-~K5AUmh|62H4*`UAtItO$O(q5?6jj+K^oD!04r=6#dsxp?~}{`?&sXn#q2 zGuY~7>O2=!u@@Kfu7q=W*4egu@qPMRM>(eyYyaIE<|j%d=iWNdGsx%c!902v#ngNg z@#U-O_4xN$s_9?(`{>{>7~-6FgWpBpqXb`Ydc3OFL#&I}Irse9F_8R@4zSS*Y*o*B zXL?6*Aw!AfkNCgcr#*yj&p3ZDe2y>v$>FUdKIy_2N~}6AbHc7gA3`6$g@1o|dE>vz z4pl(j9;kyMsjaw}lO?(?Xg%4k!5%^t#@5n=WVc&JRa+XT$~#@rldvN3S1rEpU$;XgxVny7mki3 z-Hh|jUCHrUXuLr!)`w>wgO0N%KTB-1di>cj(x3Bav`7v z3G7EIbU$z>`Nad7Rk_&OT-W{;qg)-GXV-aJT#(ozdmnA~Rq3GQ_3mby(>q6Ocb-RgTUhTN)))x>m&eD;$J5Bg zo&DhY36Yg=J=$Z>t}RJ>o|@hAcwWzN#r(WJ52^g$lh^!63@hh+dR$&_dEGu&^CR*< z!oFqSqO@>xZ*nC2oiOd0eS*F^IL~W-rsrO`J`ej{=ou_q^_(<$&-3f^J z&L^MSYWIe{&pYq&9eGaArA~*kA + +
+ {children} +
+ + + ); +} diff --git a/src/Web/MeAjudaAi.Web.Provider/src/app/page.module.css b/src/Web/MeAjudaAi.Web.Provider/src/app/page.module.css new file mode 100644 index 000000000..8a13e21cb --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Provider/src/app/page.module.css @@ -0,0 +1,2 @@ +.page { +} diff --git a/src/Web/MeAjudaAi.Web.Provider/src/app/page.tsx b/src/Web/MeAjudaAi.Web.Provider/src/app/page.tsx new file mode 100644 index 000000000..3fd63bbfb --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Provider/src/app/page.tsx @@ -0,0 +1,74 @@ +import { Button } from "../components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; + +export default function Index() { + return ( +
+
+

+ Dashboard do Prestador +

+

+ Bem-vindo de volta! Aqui você pode gerenciar seu perfil e informações. +

+
+ +
+ {/* Profile Status Card */} + + + Status do Perfil + + +
+
+

Ativo e visível

+
+

+ Seu perfil está acessível nas buscas da sua região. +

+ + + + + {/* Services Configuration Card */} + + + Serviços + + +

+ Configure seus serviços ofericidos, fotos, e região de atuação. +

+ +
+
+ + {/* Verification / Upload Card */} + + + Documentos + + +

+ Sua conta requer envio de documentos para validação final de segurança. +

+
+

Documento de Identidade

+

+ Envie a frente e o verso do seu RG ou CNH vigente. +

+
+ +
+
+
+
+ ); +} diff --git a/src/Web/MeAjudaAi.Web.Provider/src/components/ui/button.tsx b/src/Web/MeAjudaAi.Web.Provider/src/components/ui/button.tsx new file mode 100644 index 000000000..386733b50 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Provider/src/components/ui/button.tsx @@ -0,0 +1,54 @@ +import { tv, type VariantProps } from "tailwind-variants"; +import { twMerge } from "tailwind-merge"; +import type { ComponentProps } from "react"; + +export const buttonVariants = tv({ + base: [ + "inline-flex cursor-pointer items-center justify-center font-medium rounded-lg border transition-colors", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + ], + variants: { + variant: { + primary: + "border-primary bg-primary text-primary-foreground hover:bg-primary-hover", + secondary: + "border-border bg-secondary text-secondary-foreground hover:bg-muted", + ghost: + "border-transparent bg-transparent text-muted-foreground hover:text-foreground", + destructive: + "border-destructive bg-destructive text-destructive-foreground hover:bg-destructive/90", + }, + size: { + sm: "h-6 px-2 gap-1.5 text-xs [&_svg]:size-3", + md: "h-7 px-3 gap-2 text-sm [&_svg]:size-3.5", + lg: "h-9 px-4 gap-2.5 text-base [&_svg]:size-4", + }, + }, + defaultVariants: { variant: "primary", size: "md" }, +}); + +export interface ButtonProps + extends ComponentProps<"button">, VariantProps {} + +export function Button({ + className, + variant, + size, + disabled, + children, + ...props +}: ButtonProps) { + return ( + + ); +} diff --git a/src/Web/MeAjudaAi.Web.Provider/src/components/ui/card.tsx b/src/Web/MeAjudaAi.Web.Provider/src/components/ui/card.tsx new file mode 100644 index 000000000..05b273149 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Provider/src/components/ui/card.tsx @@ -0,0 +1,41 @@ +import { twMerge } from "tailwind-merge"; +import type { ComponentProps } from "react"; + +export interface CardProps extends ComponentProps<"div"> {} + +export function Card({ className, ...props }: CardProps) { + return ( +
+ ); +} + +export function CardHeader({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +export function CardTitle({ className, ...props }: ComponentProps<"h3">) { + return ( +

+ ); +} + +export function CardContent({ className, ...props }: ComponentProps<"div">) { + return
; +} diff --git a/src/Web/MeAjudaAi.Web.Provider/tsconfig.json b/src/Web/MeAjudaAi.Web.Provider/tsconfig.json new file mode 100644 index 000000000..e05854e16 --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Provider/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + + "strict": true, + "noEmit": true, + "emitDeclarationOnly": false, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + + "../MeAjudaAi.Web.Provider/.next/types/**/*.ts", + "../dist/MeAjudaAi.Web.Provider/.next/types/**/*.ts", + + "next-env.d.ts" + ], + "exclude": [ + "node_modules", + "jest.config.ts", + "jest.config.cts", + "**/*.spec.ts", + "**/*.test.ts" + ] +} diff --git a/src/Web/nx.json b/src/Web/nx.json index 15f787fe6..be374491e 100644 --- a/src/Web/nx.json +++ b/src/Web/nx.json @@ -22,12 +22,21 @@ "options": { "targetName": "test" } + }, + { + "plugin": "@nx/next/plugin", + "options": { + "startTargetName": "next:start", + "buildTargetName": "next:build", + "devTargetName": "next:dev", + "serveStaticTargetName": "serve-static", + "buildDepsTargetName": "build-deps", + "watchDepsTargetName": "watch-deps" + } } ], "namedInputs": { - "default": [ - "{projectRoot}/**/*" - ], + "default": ["{projectRoot}/**/*"], "production": [ "default", "!{projectRoot}/**/*.Tests/**/*", @@ -59,18 +68,19 @@ "linter": "none", "unitTestRunner": "none" } + }, + "@nx/next": { + "application": { + "style": "css", + "linter": "none" + } } }, "targetDefaults": { "@nx/js:tsc": { "cache": true, - "dependsOn": [ - "^build" - ], - "inputs": [ - "production", - "^production" - ] + "dependsOn": ["^build"], + "inputs": ["production", "^production"] } } -} \ No newline at end of file +} diff --git a/src/Web/package-lock.json b/src/Web/package-lock.json index db43df15d..8addaabe3 100644 --- a/src/Web/package-lock.json +++ b/src/Web/package-lock.json @@ -14,6 +14,7 @@ "MeAjudaAi.Web.Customer" ], "dependencies": { + "@base-ui/react": "^1.3.0", "@hey-api/client-fetch": "^0.13.1", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -25,6 +26,7 @@ "date-fns": "^4.1.0", "jose": "^6.2.1", "lucide-react": "^0.469.0", + "next": "~15.2.4", "next-auth": "4.24.13", "next-themes": "^0.4.6", "react": "^19.0.0", @@ -52,6 +54,7 @@ "@nx/web": "22.5.4", "@playwright/test": "^1.36.0", "@swc-node/register": "~1.11.1", + "@swc/cli": "~0.8.0", "@swc/core": "~1.15.5", "@swc/helpers": "~0.5.18", "@tailwindcss/postcss": "^4.2.1", @@ -165,6 +168,15 @@ "url": "https://eslint.org/donate" } }, + "MeAjudaAi.Web.Customer/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "MeAjudaAi.Web.Customer/node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -414,6 +426,89 @@ "node": "*" } }, + "MeAjudaAi.Web.Customer/node_modules/next": { + "version": "15.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-15.1.6.tgz", + "integrity": "sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "15.1.6", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.1.6", + "@next/swc-darwin-x64": "15.1.6", + "@next/swc-linux-arm64-gnu": "15.1.6", + "@next/swc-linux-arm64-musl": "15.1.6", + "@next/swc-linux-x64-gnu": "15.1.6", + "@next/swc-linux-x64-musl": "15.1.6", + "@next/swc-win32-arm64-msvc": "15.1.6", + "@next/swc-win32-x64-msvc": "15.1.6", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "MeAjudaAi.Web.Customer/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -2501,6 +2596,59 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.3.0.tgz", + "integrity": "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@base-ui/utils": "0.2.6", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.4.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.6.tgz", + "integrity": "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -2508,6 +2656,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", @@ -6799,71 +6958,82 @@ "@module-federation/sdk": "0.21.6" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", - "integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emnapi/core": "^1.1.0", - "@emnapi/runtime": "^1.1.0", - "@tybys/wasm-util": "^0.9.0" - } - }, - "node_modules/@next/env": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.6.tgz", - "integrity": "sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==", - "license": "MIT" - }, - "node_modules/@next/eslint-plugin-next": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.6.tgz", - "integrity": "sha512-+slMxhTgILUntZDGNgsKEYHUvpn72WP1YTlkmEhS51vnVd7S9jEEy0n9YAMcI21vUG4akTw9voWH02lrClt/yw==", + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", "dev": true, "license": "MIT", - "dependencies": { - "fast-glob": "3.3.1" - } - }, - "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8.6.0" + "node": ">= 10" } }, - "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 6" + "node": ">= 10" } }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz", - "integrity": "sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==", + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6873,13 +7043,14 @@ "node": ">= 10" } }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz", - "integrity": "sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==", + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6889,29 +7060,31 @@ "node": ">= 10" } }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz", - "integrity": "sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==", + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", "cpu": [ - "arm64" + "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { "node": ">= 10" } }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz", - "integrity": "sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==", + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", "cpu": [ - "arm64" + "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6921,13 +7094,14 @@ "node": ">= 10" } }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz", - "integrity": "sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==", + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", "cpu": [ - "x64" + "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6937,13 +7111,14 @@ "node": ">= 10" } }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz", - "integrity": "sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==", + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", "cpu": [ - "x64" + "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6953,12 +7128,319 @@ "node": ">= 10" } }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz", - "integrity": "sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==", + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", "cpu": [ - "arm64" + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", + "integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emnapi/core": "^1.1.0", + "@emnapi/runtime": "^1.1.0", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@next/env": { + "version": "15.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.6.tgz", + "integrity": "sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.1.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.6.tgz", + "integrity": "sha512-+slMxhTgILUntZDGNgsKEYHUvpn72WP1YTlkmEhS51vnVd7S9jEEy0n9YAMcI21vUG4akTw9voWH02lrClt/yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz", + "integrity": "sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz", + "integrity": "sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz", + "integrity": "sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz", + "integrity": "sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz", + "integrity": "sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz", + "integrity": "sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz", + "integrity": "sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==", + "cpu": [ + "arm64" ], "license": "MIT", "optional": true, @@ -9894,6 +10376,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -10225,35 +10720,133 @@ "pirates": "^4.0.7", "tslib": "^2.8.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@swc/core": ">= 1.4.13", + "typescript": ">= 4.3" + } + }, + "node_modules/@swc-node/sourcemap-support": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@swc-node/sourcemap-support/-/sourcemap-support-0.6.1.tgz", + "integrity": "sha512-ovltDVH5QpdHXZkW138vG4+dgcNsxfwxHVoV6BtmTbz2KKl1A8ZSlbdtxzzfNjCjbpayda8Us9eMtcHobm38dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map-support": "^0.5.21", + "tslib": "^2.8.1" + } + }, + "node_modules/@swc-node/sourcemap-support/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/@swc/cli": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@swc/cli/-/cli-0.8.0.tgz", + "integrity": "sha512-vzUkYzlqLe9dC+B0ZIH62CzfSZOCTjIsmquYyyyi45JCm1xmRfLDKeEeMrEPPyTWnEEN84e4iVd49Tgqa+2GaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@swc/counter": "^0.1.3", + "@xhmikosr/bin-wrapper": "^13.0.5", + "commander": "^8.3.0", + "minimatch": "^9.0.3", + "piscina": "^4.3.1", + "semver": "^7.3.8", + "slash": "3.0.0", + "source-map": "^0.7.3", + "tinyglobby": "^0.2.13" + }, + "bin": { + "spack": "bin/spack.js", + "swc": "bin/swc.js", + "swcx": "bin/swcx.js" + }, + "engines": { + "node": ">= 20.19.0" + }, + "peerDependencies": { + "@swc/core": "^1.2.66", + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@swc/cli/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/cli/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@swc/cli/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@swc/cli/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" }, - "peerDependencies": { - "@swc/core": ">= 1.4.13", - "typescript": ">= 4.3" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@swc-node/sourcemap-support": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@swc-node/sourcemap-support/-/sourcemap-support-0.6.1.tgz", - "integrity": "sha512-ovltDVH5QpdHXZkW138vG4+dgcNsxfwxHVoV6BtmTbz2KKl1A8ZSlbdtxzzfNjCjbpayda8Us9eMtcHobm38dA==", + "node_modules/@swc/cli/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", - "dependencies": { - "source-map-support": "^0.5.21", - "tslib": "^2.8.1" + "engines": { + "node": ">=8" } }, - "node_modules/@swc-node/sourcemap-support/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "node_modules/@swc/cli/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" } }, "node_modules/@swc/core": { @@ -10491,6 +11084,19 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -10827,6 +11433,32 @@ "react": "^18 || ^19" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "dev": true, + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -11055,6 +11687,13 @@ "@types/node": "*" } }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -12306,6 +12945,189 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xhmikosr/archive-type": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/archive-type/-/archive-type-7.1.0.tgz", + "integrity": "sha512-xZEpnGplg1sNPyEgFh0zbHxqlw5dtYg6viplmWSxUj12+QjU9SKu3U/2G73a15pEjLaOqTefNSZ1fOPUOT4Xgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^20.5.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/bin-check": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/bin-check/-/bin-check-7.1.0.tgz", + "integrity": "sha512-y1O95J4mnl+6MpVmKfMYXec17hMEwE/yeCglFNdx+QvLLtP0yN4rSYcbkXnth+lElBuKKek2NbvOfOGPpUXCvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "isexe": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/bin-wrapper": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/bin-wrapper/-/bin-wrapper-13.2.0.tgz", + "integrity": "sha512-t9U9X0sDPRGDk5TGx4dv5xiOvniVJpXnfTuynVKwHgtib95NYEw4MkZdJqhoSiz820D9m0o6PCqOPMXz0N9fIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xhmikosr/bin-check": "^7.1.0", + "@xhmikosr/downloader": "^15.2.0", + "@xhmikosr/os-filter-obj": "^3.0.0", + "bin-version-check": "^5.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/decompress": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress/-/decompress-10.2.0.tgz", + "integrity": "sha512-MmDBvu0+GmADyQWHolcZuIWffgfnuTo4xpr2I/Qw5Ox0gt+e1Be7oYqJM4te5ylL6mzlcoicnHVDvP27zft8tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xhmikosr/decompress-tar": "^8.1.0", + "@xhmikosr/decompress-tarbz2": "^8.1.0", + "@xhmikosr/decompress-targz": "^8.1.0", + "@xhmikosr/decompress-unzip": "^7.1.0", + "graceful-fs": "^4.2.11", + "strip-dirs": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/decompress-tar": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-tar/-/decompress-tar-8.1.0.tgz", + "integrity": "sha512-m0q8x6lwxenh1CrsTby0Jrjq4vzW/QU1OLhTHMQLEdHpmjR1lgahGz++seZI0bXF3XcZw3U3xHfqZSz+JPP2Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^20.5.0", + "is-stream": "^2.0.1", + "tar-stream": "^3.1.7" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/decompress-tar/node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/@xhmikosr/decompress-tarbz2": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-tarbz2/-/decompress-tarbz2-8.1.0.tgz", + "integrity": "sha512-aCLfr3A/FWZnOu5eqnJfme1Z1aumai/WRw55pCvBP+hCGnTFrcpsuiaVN5zmWTR53a8umxncY2JuYsD42QQEbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xhmikosr/decompress-tar": "^8.0.1", + "file-type": "^20.5.0", + "is-stream": "^2.0.1", + "seek-bzip": "^2.0.0", + "unbzip2-stream": "^1.4.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/decompress-targz": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-targz/-/decompress-targz-8.1.0.tgz", + "integrity": "sha512-fhClQ2wTmzxzdz2OhSQNo9ExefrAagw93qaG1YggoIz/QpI7atSRa7eOHv4JZkpHWs91XNn8Hry3CwUlBQhfPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xhmikosr/decompress-tar": "^8.0.1", + "file-type": "^20.5.0", + "is-stream": "^2.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/decompress-unzip": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-unzip/-/decompress-unzip-7.1.0.tgz", + "integrity": "sha512-oqTYAcObqTlg8owulxFTqiaJkfv2SHsxxxz9Wg4krJAHVzGWlZsU8tAB30R6ow+aHrfv4Kub6WQ8u04NWVPUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^20.5.0", + "get-stream": "^6.0.1", + "yauzl": "^3.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/downloader": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/downloader/-/downloader-15.2.0.tgz", + "integrity": "sha512-lAqbig3uRGTt0sHNIM4vUG9HoM+mRl8K28WuYxyXLCUT6pyzl4Y4i0LZ3jMEsCYZ6zjPZbO9XkG91OSTd4si7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xhmikosr/archive-type": "^7.1.0", + "@xhmikosr/decompress": "^10.2.0", + "content-disposition": "^0.5.4", + "defaults": "^2.0.2", + "ext-name": "^5.0.0", + "file-type": "^20.5.0", + "filenamify": "^6.0.0", + "get-stream": "^6.0.1", + "got": "^13.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/downloader/node_modules/defaults": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-2.0.2.tgz", + "integrity": "sha512-cuIw0PImdp76AOfgkjbW4VhQODRmNNcKR73vdCH5cLd/ifj7aamfoXvYgfGkEAjNJZ3ozMIy9Gu2LutUkGEPbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@xhmikosr/os-filter-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/os-filter-obj/-/os-filter-obj-3.0.0.tgz", + "integrity": "sha512-siPY6BD5dQ2SZPl3I0OZBHL27ZqZvLEosObsZRQ1NUB8qcxegwt0T9eKtV96JMFQpIz1elhkzqOg4c/Ri6Dp9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^3.0.0" + }, + "engines": { + "node": "^14.14.0 || >=16.0.0" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -12614,6 +13436,27 @@ "resolved": "libs/api-client", "link": true }, + "node_modules/arch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", + "integrity": "sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -12980,6 +13823,21 @@ "node": ">= 0.4" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -13215,26 +14073,119 @@ "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, - "license": "MIT", + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.6.tgz", + "integrity": "sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz", + "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.10.0.tgz", + "integrity": "sha512-DOPZF/DDcDruKDA43cOw6e9Quq5daua7ygcAwJE/pKJsRWhgSSemi7qVNGE5kyDIxIeN1533G/zfbvWX7Wcb9w==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "streamx": "^2.25.0", + "teex": "^1.0.1" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" } }, "node_modules/base64-js": { @@ -13308,6 +14259,41 @@ "node": "*" } }, + "node_modules/bin-version": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-6.0.0.tgz", + "integrity": "sha512-nk5wEsP4RiKjG+vF+uG8lFsEn4d7Y6FVDamzzftSunXOoOcOOkzcWdKVlGgFFwlUQCj63SgnUkLLGF8v7lufhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "find-versions": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bin-version-check": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bin-version-check/-/bin-version-check-5.1.0.tgz", + "integrity": "sha512-bYsvMqJ8yNGILLz1KP9zKLzQ6YpljV3ln1gqhuLkUtyfGi3qXKGuK2p+U4NAvjVFzDFiBBtOpCOSFNuYYEGZ5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bin-version": "^6.0.0", + "semver": "^7.5.3", + "semver-truncate": "^3.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -13527,6 +14513,16 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -13608,21 +14604,6 @@ } } }, - "node_modules/c12/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/c12/node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -13644,17 +14625,33 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/c12/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "engines": { + "node": ">=14.16" } }, "node_modules/call-bind": { @@ -13801,41 +14798,18 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" } }, "node_modules/chrome-trace-event": { @@ -15803,6 +16777,35 @@ "dev": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -15883,6 +16886,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -17376,6 +18389,16 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -17548,6 +18571,33 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/ext-list": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", + "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.28.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ext-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", + "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ext-list": "^2.0.0", + "sort-keys-length": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -17555,6 +18605,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -17703,6 +18760,25 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.5.0.tgz", + "integrity": "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -17743,6 +18819,35 @@ "node": ">=10" } }, + "node_modules/filename-reserved-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz", + "integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/filenamify": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz", + "integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -17852,6 +18957,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-versions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-5.1.0.tgz", + "integrity": "sha512-+iwzCJ7C5v5KgcBuueqVoNiHVoQpwiUK5XFLjf0affFTep+Wcw93tPvmb8tqujDNmzhBDPddnWV/qgWSXgq+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver-regex": "^4.0.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -18031,6 +19152,31 @@ "concat-map": "0.0.1" } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -18053,14 +19199,27 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, - "license": "MIT", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "is-glob": "^4.0.1" }, "engines": { - "node": ">=12" + "node": ">= 6" } }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/json-schema-traverse": { @@ -18083,6 +19242,32 @@ "node": "*" } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -18129,6 +19314,16 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.17" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -18645,6 +19840,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", + "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -18929,6 +20150,13 @@ "node": ">= 0.6" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -19040,6 +20268,20 @@ "node": ">=12" } }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -19307,6 +20549,16 @@ "dev": true, "license": "ISC" }, + "node_modules/inspect-with-kind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/inspect-with-kind/-/inspect-with-kind-1.0.5.tgz", + "integrity": "sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "kind-of": "^6.0.2" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -23614,6 +24866,19 @@ "tslib": "^2.0.3" } }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -23850,6 +25115,19 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.4.7", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.4.7.tgz", @@ -24031,13 +25309,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-15.1.6.tgz", - "integrity": "sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==", - "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "version": "15.2.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.2.9.tgz", + "integrity": "sha512-jXEBIPi+kIkMe5KI4okvGIWvot9hyiDz2fT4OqxxsSeZTA6zhSwrQkJwTE3GmQ1HQlolcQjTNMjHMvc8hhog7g==", "license": "MIT", "dependencies": { - "@next/env": "15.1.6", + "@next/env": "15.2.9", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", @@ -24052,14 +25329,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.1.6", - "@next/swc-darwin-x64": "15.1.6", - "@next/swc-linux-arm64-gnu": "15.1.6", - "@next/swc-linux-arm64-musl": "15.1.6", - "@next/swc-linux-x64-gnu": "15.1.6", - "@next/swc-linux-x64-musl": "15.1.6", - "@next/swc-win32-arm64-msvc": "15.1.6", - "@next/swc-win32-x64-msvc": "15.1.6", + "@next/swc-darwin-arm64": "15.2.5", + "@next/swc-darwin-x64": "15.2.5", + "@next/swc-linux-arm64-gnu": "15.2.5", + "@next/swc-linux-arm64-musl": "15.2.5", + "@next/swc-linux-x64-gnu": "15.2.5", + "@next/swc-linux-x64-musl": "15.2.5", + "@next/swc-win32-arm64-msvc": "15.2.5", + "@next/swc-win32-x64-msvc": "15.2.5", "sharp": "^0.33.5" }, "peerDependencies": { @@ -24154,6 +25431,140 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/next/node_modules/@next/env": { + "version": "15.2.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.9.tgz", + "integrity": "sha512-0JJ6OlIb1kZiAbY/Hi5XHb2ZT7B5/l8CyGX3GxtTY8LNl1Inm9EU8PnCtVzUR8N2Si3a1pX02PbKBlDcsHNvUQ==", + "license": "MIT" + }, + "node_modules/next/node_modules/@next/swc-darwin-arm64": { + "version": "15.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.5.tgz", + "integrity": "sha512-4OimvVlFTbgzPdA0kh8A1ih6FN9pQkL4nPXGqemEYgk+e7eQhsst/p35siNNqA49eQA6bvKZ1ASsDtu9gtXuog==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next/node_modules/@next/swc-darwin-x64": { + "version": "15.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.5.tgz", + "integrity": "sha512-ohzRaE9YbGt1ctE0um+UGYIDkkOxHV44kEcHzLqQigoRLaiMtZzGrA11AJh2Lu0lv51XeiY1ZkUvkThjkVNBMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next/node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.5.tgz", + "integrity": "sha512-FMSdxSUt5bVXqqOoZCc/Seg4LQep9w/fXTazr/EkpXW2Eu4IFI9FD7zBDlID8TJIybmvKk7mhd9s+2XWxz4flA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next/node_modules/@next/swc-linux-arm64-musl": { + "version": "15.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.5.tgz", + "integrity": "sha512-4ZNKmuEiW5hRKkGp2HWwZ+JrvK4DQLgf8YDaqtZyn7NYdl0cHfatvlnLFSWUayx9yFAUagIgRGRk8pFxS8Qniw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next/node_modules/@next/swc-linux-x64-gnu": { + "version": "15.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.5.tgz", + "integrity": "sha512-bE6lHQ9GXIf3gCDE53u2pTl99RPZW5V1GLHSRMJ5l/oB/MT+cohu9uwnCK7QUph2xIOu2a6+27kL0REa/kqwZw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next/node_modules/@next/swc-linux-x64-musl": { + "version": "15.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.5.tgz", + "integrity": "sha512-y7EeQuSkQbTAkCEQnJXm1asRUuGSWAchGJ3c+Qtxh8LVjXleZast8Mn/rL7tZOm7o35QeIpIcid6ufG7EVTTcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next/node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.5.tgz", + "integrity": "sha512-gQMz0yA8/dskZM2Xyiq2FRShxSrsJNha40Ob/M2n2+JGRrZ0JwTVjLdvtN6vCxuq4ByhOd4a9qEf60hApNR2gQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next/node_modules/@next/swc-win32-x64-msvc": { + "version": "15.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.5.tgz", + "integrity": "sha512-tBDNVUcI7U03+3oMvJ11zrtVin5p0NctiuKmTGyaTIEAVj9Q77xukLXGXRnWxKRIIdFG4OTA2rUVGZDYOwgmAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/next/node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -24319,6 +25730,19 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -24830,6 +26254,16 @@ "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -25056,6 +26490,13 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -25102,6 +26543,16 @@ "node": ">= 6" } }, + "node_modules/piscina": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.9.2.tgz", + "integrity": "sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@napi-rs/nice": "^1.0.1" + } + }, "node_modules/pkg-dir": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", @@ -26218,6 +27669,19 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rambda": { "version": "9.4.2", "resolved": "https://registry.npmjs.org/rambda/-/rambda-9.4.2.tgz", @@ -26427,45 +27891,32 @@ "node": ">=0.10.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": ">=8.10.0" + "node": ">= 6" } }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">= 20.19.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/reflect-metadata": { @@ -26604,6 +28055,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -26622,6 +28079,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -26689,6 +28153,22 @@ "node": ">=10" } }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -27681,6 +29161,30 @@ "dev": true, "license": "MIT" }, + "node_modules/seek-bzip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz", + "integrity": "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^6.0.0" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, + "node_modules/seek-bzip/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -27715,6 +29219,35 @@ "node": ">=10" } }, + "node_modules/semver-regex": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", + "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver-truncate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/semver-truncate/-/semver-truncate-3.0.0.tgz", + "integrity": "sha512-LJWA9kSvMolR51oDE6PN3kALBNaUdkxzAGcexw8gjMA8xr5zUqK0JiR3CgARSqanYF3Z1YHvsErb1KDgh+v7Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -28178,6 +29711,42 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/sort-keys": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", + "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-keys-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", + "integrity": "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "sort-keys": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-keys/node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sorted-array-functions": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", @@ -28401,6 +29970,18 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -28613,6 +30194,27 @@ "node": ">=4" } }, + "node_modules/strip-dirs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-3.0.0.tgz", + "integrity": "sha512-I0sdgcFTfKQlUPZyAqPJmSG3HLO9rWDFnxonnIbskYNM3DwFOeTNB5KzVq3dA1GdRAc/25b5Y7UO2TQfKWw4aQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "inspect-with-kind": "^1.0.5", + "is-plain-obj": "^1.1.0" + } + }, + "node_modules/strip-dirs/node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -28636,6 +30238,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", @@ -28808,6 +30427,12 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -28881,6 +30506,16 @@ "node": ">=6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", @@ -29051,6 +30686,16 @@ "node": "*" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -29075,6 +30720,13 @@ "tslib": "^2" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -29178,6 +30830,25 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -29631,6 +31302,19 @@ "node": ">=0.8.0" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -29650,6 +31334,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -29881,6 +31576,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -30383,6 +32087,31 @@ } } }, + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -30396,6 +32125,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webpack-dev-server/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", @@ -30450,6 +32192,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webpack-dev-server/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/webpack-dev-server/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/webpack-merge": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", @@ -30954,6 +32722,20 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.1.tgz", + "integrity": "sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/src/Web/package.json b/src/Web/package.json index a7a965349..fd1d00292 100644 --- a/src/Web/package.json +++ b/src/Web/package.json @@ -41,6 +41,7 @@ "@nx/web": "22.5.4", "@playwright/test": "^1.36.0", "@swc-node/register": "~1.11.1", + "@swc/cli": "~0.8.0", "@swc/core": "~1.15.5", "@swc/helpers": "~0.5.18", "@tailwindcss/postcss": "^4.2.1", @@ -72,6 +73,7 @@ }, "nx": {}, "dependencies": { + "@base-ui/react": "^1.3.0", "@hey-api/client-fetch": "^0.13.1", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -83,6 +85,7 @@ "date-fns": "^4.1.0", "jose": "^6.2.1", "lucide-react": "^0.469.0", + "next": "~15.2.4", "next-auth": "4.24.13", "next-themes": "^0.4.6", "react": "^19.0.0", diff --git a/src/Web/tsconfig.base.json b/src/Web/tsconfig.base.json index cd7a361d7..da6c5000d 100644 --- a/src/Web/tsconfig.base.json +++ b/src/Web/tsconfig.base.json @@ -10,30 +10,16 @@ "importHelpers": true, "target": "es2015", "module": "esnext", - "lib": [ - "es2020", - "dom" - ], + "lib": ["es2020", "dom"], "skipLibCheck": true, "skipDefaultLibCheck": true, "baseUrl": ".", "paths": { - "ui": [ - "libs/ui/src/index.ts" - ], - "auth": [ - "libs/auth/src/index.ts" - ], - "api-client": [ - "libs/api-client/src/index.ts" - ], - "Shared.Contracts": [ - "../Shared/MeAjudaAi.Shared.Contracts" - ] + "ui": ["libs/ui/src/index.ts"], + "auth": ["libs/auth/src/index.ts"], + "api-client": ["libs/api-client/src/index.ts"], + "Shared.Contracts": ["../Shared/MeAjudaAi.Shared.Contracts"] } }, - "exclude": [ - "node_modules", - "tmp" - ] -} \ No newline at end of file + "exclude": ["node_modules", "tmp"] +}