diff --git a/.env.example b/.env.example index d886060d..6da9fc87 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,10 @@ SEED_ADMIN_PASSWORD=SuaSenhaAdmin123! # WhatsApp via Meta Cloud API (opcional — NullWhatsAppNotifier se PhoneNumberId/AccessToken vazios) # Onde pegar: Meta App > WhatsApp > API Setup (PhoneNumberId, token), App > Settings > Basic (AppSecret). +# +# WHATSAPP_HABILITADO: kill-switch explícito. false (default) = NullWhatsAppNotifier SEMPRE, +# mesmo com credenciais válidas configuradas. Só true reativa o envio real. +# WHATSAPP_HABILITADO=false # WHATSAPP_PHONE_NUMBER_ID= # WHATSAPP_ACCESS_TOKEN= # System User token (não o temporário de 24h) # WHATSAPP_API_VERSION=v21.0 diff --git a/docker-compose.homolog.yml b/docker-compose.homolog.yml index 874ea824..a3bdd6bb 100644 --- a/docker-compose.homolog.yml +++ b/docker-compose.homolog.yml @@ -31,6 +31,7 @@ services: Seed__ZapTestEmail: ${SEED_ZAP_TEST_EMAIL:-zap-test@forzion.tech} Seed__ZapTestPassword: ${SEED_ZAP_TEST_PASSWORD:-} # WhatsApp via Meta Cloud API (opcional — PhoneNumberId+AccessToken vazios → NullWhatsAppNotifier) + WhatsApp__Habilitado: ${WHATSAPP_HABILITADO:-false} WhatsApp__PhoneNumberId: ${WHATSAPP_PHONE_NUMBER_ID:-} WhatsApp__AccessToken: ${WHATSAPP_ACCESS_TOKEN:-} WhatsApp__ApiVersion: ${WHATSAPP_API_VERSION:-v21.0} diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 9ac0c65a..2b61cb6b 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -25,6 +25,7 @@ services: Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:-} Seed__ZapTestEmail: ${SEED_ZAP_TEST_EMAIL:-zap-test@forzion.tech} Seed__ZapTestPassword: ${SEED_ZAP_TEST_PASSWORD:-} + WhatsApp__Habilitado: ${WHATSAPP_HABILITADO:-false} WhatsApp__PhoneNumberId: ${WHATSAPP_PHONE_NUMBER_ID:-} WhatsApp__AccessToken: ${WHATSAPP_ACCESS_TOKEN:-} WhatsApp__ApiVersion: ${WHATSAPP_API_VERSION:-v21.0} diff --git a/forzion.tech.Api/Endpoints/Pagamentos/WebhookEndpoints.cs b/forzion.tech.Api/Endpoints/Pagamentos/WebhookEndpoints.cs index 05eef564..8cdf5d08 100644 --- a/forzion.tech.Api/Endpoints/Pagamentos/WebhookEndpoints.cs +++ b/forzion.tech.Api/Endpoints/Pagamentos/WebhookEndpoints.cs @@ -126,6 +126,9 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild [FromServices] ILogger logger, CancellationToken cancellationToken) => { + if (!configuration.GetValue("WhatsApp:Habilitado")) + return Results.Ok(); + httpContext.Request.Body = new LimitedStream(httpContext.Request.Body, MaxWebhookBodyBytes); using var reader = new StreamReader(httpContext.Request.Body); diff --git a/forzion.tech.Application/Settings/WhatsAppSettings.cs b/forzion.tech.Application/Settings/WhatsAppSettings.cs index ecbc28c0..8702a20d 100644 --- a/forzion.tech.Application/Settings/WhatsAppSettings.cs +++ b/forzion.tech.Application/Settings/WhatsAppSettings.cs @@ -8,6 +8,9 @@ namespace forzion.tech.Application.Settings; /// public class WhatsAppSettings { + /// Kill-switch explícito. Default false: DI força NullWhatsAppNotifier mesmo com credenciais presentes. + public bool Habilitado { get; set; } + public bool MarcarComoTeste { get; set; } /// CSV de telefones (E.164). Em não-prod, redireciona o destinatário p/ o 1º alvo. diff --git a/forzion.tech.Infrastructure/DependencyInjection/InfrastructureExtensions.cs b/forzion.tech.Infrastructure/DependencyInjection/InfrastructureExtensions.cs index c374fbc1..5e11c1fd 100644 --- a/forzion.tech.Infrastructure/DependencyInjection/InfrastructureExtensions.cs +++ b/forzion.tech.Infrastructure/DependencyInjection/InfrastructureExtensions.cs @@ -343,10 +343,11 @@ public static IServiceCollection AddInfrastructure( // WhatsApp notifier — Meta Cloud API when configured, no-op otherwise. Sempre // embrulhado no EnvironmentWhatsAppDecorator: passthrough em prod, redirect/allowlist // de telefone em não-prod. + var whatsAppHabilitado = configuration.GetValue("WhatsApp:Habilitado"); var whatsAppPhoneNumberId = configuration["WhatsApp:PhoneNumberId"]; var whatsAppAccessToken = configuration["WhatsApp:AccessToken"]; var whatsAppApiVersion = configuration["WhatsApp:ApiVersion"] ?? "v21.0"; - if (!string.IsNullOrWhiteSpace(whatsAppPhoneNumberId) && !string.IsNullOrWhiteSpace(whatsAppAccessToken)) + if (whatsAppHabilitado && !string.IsNullOrWhiteSpace(whatsAppPhoneNumberId) && !string.IsNullOrWhiteSpace(whatsAppAccessToken)) { services.AddHttpClient(client => { diff --git a/forzion.tech.Infrastructure/Health/WhatsAppHealthCheck.cs b/forzion.tech.Infrastructure/Health/WhatsAppHealthCheck.cs index fb3e553f..629e4fc3 100644 --- a/forzion.tech.Infrastructure/Health/WhatsAppHealthCheck.cs +++ b/forzion.tech.Infrastructure/Health/WhatsAppHealthCheck.cs @@ -12,6 +12,9 @@ public async Task CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { + if (!configuration.GetValue("WhatsApp:Habilitado")) + return HealthCheckResult.Healthy("WhatsApp desativado temporariamente."); + var phoneNumberId = configuration["WhatsApp:PhoneNumberId"]; var accessToken = configuration["WhatsApp:AccessToken"]; diff --git a/forzion.tech.Infrastructure/Migrations/20260801201209_InativarPlanoProPlus.Designer.cs b/forzion.tech.Infrastructure/Migrations/20260801201209_InativarPlanoProPlus.Designer.cs new file mode 100644 index 00000000..d4bdbeae --- /dev/null +++ b/forzion.tech.Infrastructure/Migrations/20260801201209_InativarPlanoProPlus.Designer.cs @@ -0,0 +1,2811 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using forzion.tech.Infrastructure.Persistence; + +#nullable disable + +namespace forzion.tech.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260801201209_InativarPlanoProPlus")] + partial class InativarPlanoProPlus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text") + .HasColumnName("friendly_name"); + + b.Property("Xml") + .HasColumnType("text") + .HasColumnName("xml"); + + b.HasKey("Id") + .HasName("pk_data_protection_keys"); + + b.ToTable("data_protection_keys", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Aluno", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Anonimizado") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("anonimizado"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DiasDisponiveis") + .HasColumnType("integer") + .HasColumnName("dias_disponiveis"); + + b.Property("Doencas") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("doencas"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("Finalidade") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("finalidade"); + + b.Property("FocoTreino") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("foco_treino"); + + b.Property("LimitacoesFisicas") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("limitacoes_fisicas"); + + b.Property("NivelCondicionamento") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("nivel_condicionamento"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("nome"); + + b.Property("ObservacoesAdicionais") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("observacoes_adicionais"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Telefone") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("telefone"); + + b.Property("TempoDisponivelMinutos") + .HasColumnType("integer") + .HasColumnName("tempo_disponivel_minutos"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_alunos"); + + b.HasIndex("ContaId") + .HasDatabaseName("ix_alunos_conta_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_alunos_status"); + + b.ToTable("alunos", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Assinante", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlunoId") + .HasColumnType("uuid") + .HasColumnName("aluno_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("nome"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_assinantes"); + + b.HasIndex("AlunoId") + .IsUnique() + .HasDatabaseName("ix_assinantes_aluno_id"); + + b.ToTable("assinantes", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.AssinaturaAluno", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlunoId") + .HasColumnType("uuid") + .HasColumnName("aluno_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DataCancelamento") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_cancelamento"); + + b.Property("DataInicio") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_inicio"); + + b.Property("DataProximaCobranca") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_proxima_cobranca"); + + b.Property("PacoteId") + .HasColumnType("uuid") + .HasColumnName("pacote_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TentativasFalhasConsecutivas") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("tentativas_falhas_consecutivas"); + + b.Property("TreinadorId") + .HasColumnType("uuid") + .HasColumnName("treinador_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Valor") + .HasColumnType("numeric(10,2)") + .HasColumnName("valor"); + + b.Property("VinculoId") + .HasColumnType("uuid") + .HasColumnName("vinculo_id"); + + b.HasKey("Id") + .HasName("pk_assinaturas_aluno"); + + b.HasIndex("AlunoId") + .HasDatabaseName("ix_assinaturas_aluno_aluno_id"); + + b.HasIndex("PacoteId") + .HasDatabaseName("ix_assinaturas_aluno_pacote_id"); + + b.HasIndex("TreinadorId") + .HasDatabaseName("ix_assinaturas_aluno_treinador_id"); + + b.HasIndex("VinculoId") + .IsUnique() + .HasDatabaseName("ix_assinaturas_aluno_vinculo_id"); + + b.HasIndex("Status", "DataProximaCobranca") + .HasDatabaseName("ix_assinaturas_aluno_status_data_proxima_cobranca"); + + b.ToTable("assinaturas_aluno", null, t => + { + t.HasCheckConstraint("ck_assinaturas_aluno_valor_nao_negativo", "\"valor\" >= 0"); + }); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.AssinaturaTreinador", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DataCancelamento") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_cancelamento"); + + b.Property("DataInicio") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_inicio"); + + b.Property("DataProximaCobranca") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_proxima_cobranca"); + + b.Property("PlanoPlataformaId") + .HasColumnType("uuid") + .HasColumnName("plano_plataforma_id"); + + b.Property("PlanoPlataformaIdAgendado") + .HasColumnType("uuid") + .HasColumnName("plano_plataforma_id_agendado"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TentativasFalhasConsecutivas") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("tentativas_falhas_consecutivas"); + + b.Property("TreinadorId") + .HasColumnType("uuid") + .HasColumnName("treinador_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Valor") + .HasColumnType("numeric(10,2)") + .HasColumnName("valor"); + + b.HasKey("Id") + .HasName("pk_assinaturas_treinador"); + + b.HasIndex("PlanoPlataformaId") + .HasDatabaseName("ix_assinaturas_treinador_plano_plataforma_id"); + + b.HasIndex("PlanoPlataformaIdAgendado") + .HasDatabaseName("ix_assinaturas_treinador_plano_plataforma_id_agendado"); + + b.HasIndex("TreinadorId") + .IsUnique() + .HasDatabaseName("ux_assinaturas_treinador_nao_cancelada_por_treinador") + .HasFilter("status <> 'Cancelada'"); + + b.HasIndex("Status", "DataProximaCobranca") + .HasDatabaseName("ix_assinaturas_treinador_status_data_proxima_cobranca"); + + b.ToTable("assinaturas_treinador", null, t => + { + t.HasCheckConstraint("ck_assinaturas_treinador_valor_nao_negativo", "\"valor\" >= 0"); + }); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Conta", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnonimizadaEm") + .HasColumnType("timestamptz") + .HasColumnName("anonimizada_em"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailVerificado") + .HasColumnType("boolean") + .HasColumnName("email_verificado"); + + b.Property("NotificacoesEngajamentoEmailOptOut") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("notificacoes_engajamento_email_opt_out"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("SessoesInvalidasAntesDeUtc") + .HasColumnType("timestamptz") + .HasColumnName("sessoes_invalidas_antes_de_utc"); + + b.Property("TipoConta") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo_conta"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("VerificadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("verificado_em"); + + b.HasKey("Id") + .HasName("pk_contas"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_contas_email"); + + b.ToTable("contas", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.ContaMfa", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AtualizadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("atualizado_em"); + + b.Property("ConfirmadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("confirmado_em"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("Habilitado") + .HasColumnType("boolean") + .HasColumnName("habilitado"); + + b.Property("TotpSecretCifrado") + .HasColumnType("text") + .HasColumnName("totp_secret_cifrado"); + + b.Property("UltimoTimeStep") + .HasColumnType("bigint") + .HasColumnName("ultimo_time_step"); + + b.HasKey("Id") + .HasName("pk_conta_mfa"); + + b.HasIndex("ContaId") + .IsUnique() + .HasDatabaseName("ix_conta_mfa_conta_id"); + + b.ToTable("conta_mfa", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.ContaRecebimento", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OnboardingCompleto") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("onboarding_completo"); + + b.Property("StripeConnectAccountId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("stripe_connect_account_id"); + + b.Property("TreinadorId") + .HasColumnType("uuid") + .HasColumnName("treinador_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_conta_recebimento"); + + b.HasIndex("StripeConnectAccountId") + .HasDatabaseName("ix_conta_recebimento_stripe_connect_account_id"); + + b.HasIndex("TreinadorId") + .IsUnique() + .HasDatabaseName("ix_conta_recebimento_treinador_id"); + + b.ToTable("conta_recebimento", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.EmailDeliveryLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("OcorridoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("ocorrido_em"); + + b.Property("RecipientEmailHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("recipient_email_hash"); + + b.Property("ResendMessageId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("resend_message_id"); + + b.HasKey("Id") + .HasName("pk_email_delivery_logs"); + + b.HasIndex("EventType") + .HasDatabaseName("ix_email_delivery_logs_event_type"); + + b.HasIndex("ResendMessageId", "EventType") + .IsUnique() + .HasDatabaseName("ix_email_delivery_logs_resend_message_id_event_type"); + + b.ToTable("email_delivery_logs", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Id") + .HasName("pk_email_verification_tokens"); + + b.HasIndex("ContaId") + .HasDatabaseName("ix_email_verification_tokens_conta_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_email_verification_tokens_token_hash"); + + b.ToTable("email_verification_tokens", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.ErrorLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Mensagem") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("mensagem"); + + b.Property("Nivel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("nivel"); + + b.Property("OcorridoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("ocorrido_em"); + + b.Property("Origem") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("origem"); + + b.HasKey("Id") + .HasName("pk_error_logs"); + + b.HasIndex("OcorridoEm") + .HasDatabaseName("ix_error_logs_ocorrido_em"); + + b.ToTable("error_logs", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.ExecucaoExercicio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CargaExecutada") + .HasColumnType("numeric(10,2)") + .HasColumnName("carga_executada"); + + b.Property("ExecucaoTreinoId") + .HasColumnType("uuid") + .HasColumnName("execucao_treino_id"); + + b.Property("Observacao") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("observacao"); + + b.Property("RepeticoesExecutadas") + .HasColumnType("integer") + .HasColumnName("repeticoes_executadas"); + + b.Property("SeriesExecutadas") + .HasColumnType("integer") + .HasColumnName("series_executadas"); + + b.Property("TreinoExercicioId") + .HasColumnType("uuid") + .HasColumnName("treino_exercicio_id"); + + b.HasKey("Id") + .HasName("pk_execucoes_exercicio"); + + b.HasIndex("ExecucaoTreinoId") + .HasDatabaseName("ix_execucoes_exercicio_execucao_treino_id"); + + b.HasIndex("TreinoExercicioId") + .HasDatabaseName("ix_execucoes_exercicio_treino_exercicio_id"); + + b.ToTable("execucoes_exercicio", null, t => + { + t.HasCheckConstraint("ck_execucoes_exercicio_repeticoes_positivo", "\"repeticoes_executadas\" > 0"); + + t.HasCheckConstraint("ck_execucoes_exercicio_series_positivo", "\"series_executadas\" > 0"); + }); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.ExecucaoTreino", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlunoId") + .HasColumnType("uuid") + .HasColumnName("aluno_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DataExecucao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_execucao"); + + b.Property("IdempotencyKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("idempotency_key"); + + b.Property("Observacao") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("observacao"); + + b.Property("TreinoId") + .HasColumnType("uuid") + .HasColumnName("treino_id"); + + b.HasKey("Id") + .HasName("pk_execucoes_treino"); + + b.HasIndex("TreinoId") + .HasDatabaseName("ix_execucoes_treino_treino_id"); + + b.HasIndex("AlunoId", "DataExecucao") + .IsDescending(false, true) + .HasDatabaseName("ix_execucoes_treino_aluno_id_data_execucao"); + + b.HasIndex("AlunoId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_execucoes_treino_aluno_id_idempotency_key_unique") + .HasFilter("idempotency_key IS NOT NULL"); + + b.ToTable("execucoes_treino", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Exercicio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ComoExecutar") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("como_executar"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Descricao") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("descricao"); + + b.Property("GrupoMuscularId") + .HasColumnType("uuid") + .HasColumnName("grupo_muscular_id"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("nome"); + + b.Property("TreinadorId") + .HasColumnType("uuid") + .HasColumnName("treinador_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("VideoId") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("video_id"); + + b.HasKey("Id") + .HasName("pk_exercicios"); + + b.HasIndex("GrupoMuscularId") + .HasDatabaseName("ix_exercicios_grupo_muscular_id"); + + b.HasIndex("TreinadorId") + .HasDatabaseName("ix_exercicios_treinador_id"); + + b.ToTable("exercicios", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.GrupoMuscular", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("nome"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_grupos_musculares"); + + b.HasIndex("Nome") + .IsUnique() + .HasDatabaseName("ix_grupos_musculares_nome"); + + b.ToTable("grupos_musculares", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.HealthReportConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ativo") + .HasColumnType("boolean") + .HasColumnName("ativo"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Destinatarios") + .IsRequired() + .HasColumnType("text") + .HasColumnName("destinatarios"); + + b.Property("HoraEnvioUtc") + .HasColumnType("time without time zone") + .HasColumnName("hora_envio_utc"); + + b.Property("IncluirEntregabilidade") + .HasColumnType("boolean") + .HasColumnName("incluir_entregabilidade"); + + b.Property("IncluirErros") + .HasColumnType("boolean") + .HasColumnName("incluir_erros"); + + b.Property("IncluirKpis") + .HasColumnType("boolean") + .HasColumnName("incluir_kpis"); + + b.Property("IncluirLiveness") + .HasColumnType("boolean") + .HasColumnName("incluir_liveness"); + + b.Property("UltimoEnvioEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("ultimo_envio_em"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_health_report_config"); + + b.ToTable("health_report_config", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.HealthSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ambiente") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ambiente"); + + b.Property("CapturadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("capturado_em"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload_json"); + + b.Property("StatusGeral") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status_geral"); + + b.HasKey("Id") + .HasName("pk_health_snapshots"); + + b.HasIndex("CapturadoEm") + .HasDatabaseName("ix_health_snapshots_capturado_em"); + + b.ToTable("health_snapshots", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.LogAprovacao", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntidadeId") + .HasColumnType("uuid") + .HasColumnName("entidade_id"); + + b.Property("EntidadeTipo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entidade_tipo"); + + b.Property("Observacao") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("observacao"); + + b.Property("RealizadoPorId") + .HasColumnType("uuid") + .HasColumnName("realizado_por_id"); + + b.Property("TipoAcao") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo_acao"); + + b.HasKey("Id") + .HasName("pk_logs_aprovacao"); + + b.HasIndex("EntidadeId") + .HasDatabaseName("ix_logs_aprovacao_entidade_id"); + + b.HasIndex("RealizadoPorId") + .HasDatabaseName("ix_logs_aprovacao_realizado_por_id"); + + b.ToTable("logs_aprovacao", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.MensagemSuporte", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Assunto") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("assunto"); + + b.Property("Categoria") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("categoria"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CriadaEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criada_em"); + + b.Property("Descricao") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("descricao"); + + b.HasKey("Id") + .HasName("pk_mensagens_suporte"); + + b.HasIndex("ContaId") + .HasDatabaseName("ix_mensagens_suporte_conta_id"); + + b.ToTable("mensagens_suporte", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.MfaChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CodigoHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("codigo_hash"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("ExpiraEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_em"); + + b.Property("Proposito") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("proposito"); + + b.Property("Tentativas") + .HasColumnType("integer") + .HasColumnName("tentativas"); + + b.Property("UsadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("usado_em"); + + b.HasKey("Id") + .HasName("pk_mfa_challenges"); + + b.HasIndex("ContaId", "Proposito") + .HasDatabaseName("ix_mfa_challenges_conta_id_proposito"); + + b.ToTable("mfa_challenges", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.MfaRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CodigoHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("codigo_hash"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("UsadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("usado_em"); + + b.HasKey("Id") + .HasName("pk_mfa_recovery_codes"); + + b.HasIndex("ContaId") + .HasDatabaseName("ix_mfa_recovery_codes_conta_id"); + + b.ToTable("mfa_recovery_codes", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.NotaFiscal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CancelamentoPendentePreEmissao") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("cancelamento_pendente_pre_emissao"); + + b.Property("ChaveAcesso") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("chave_acesso"); + + b.Property("CodigoErro") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("codigo_erro"); + + b.Property("CompetenciaFim") + .HasColumnType("date") + .HasColumnName("competencia_fim"); + + b.Property("CompetenciaInicio") + .HasColumnType("date") + .HasColumnName("competencia_inicio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DanfseRef") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("danfse_ref"); + + b.Property("DataEmissao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_emissao"); + + b.Property("MotivoCancelamentoPendente") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("motivo_cancelamento_pendente"); + + b.Property("MotivoErro") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("motivo_erro"); + + b.Property("NumeroDps") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("numero_dps"); + + b.Property("NumeroNfse") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("numero_nfse"); + + b.Property("PagamentoTreinadorId") + .HasColumnType("uuid") + .HasColumnName("pagamento_treinador_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status"); + + b.Property("Tipo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("tipo"); + + b.Property("TreinadorId") + .HasColumnType("uuid") + .HasColumnName("treinador_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Valor") + .HasColumnType("numeric(10,2)") + .HasColumnName("valor"); + + b.HasKey("Id") + .HasName("pk_notas_fiscais"); + + b.HasIndex("PagamentoTreinadorId") + .IsUnique() + .HasDatabaseName("ix_notas_fiscais_pagamento_treinador_id_unique") + .HasFilter("pagamento_treinador_id IS NOT NULL"); + + b.HasIndex("TreinadorId") + .HasDatabaseName("ix_notas_fiscais_treinador_id"); + + b.HasIndex("TreinadorId", "Tipo", "CompetenciaInicio") + .IsUnique() + .HasDatabaseName("ix_notas_fiscais_treinador_tipo_competencia_unique") + .HasFilter("competencia_inicio IS NOT NULL"); + + b.ToTable("notas_fiscais", null, t => + { + t.HasCheckConstraint("ck_notas_fiscais_valor_nao_negativo", "\"valor\" >= 0"); + }); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Notificacao", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Corpo") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("corpo"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DestinatarioContaId") + .HasColumnType("uuid") + .HasColumnName("destinatario_conta_id"); + + b.Property("DiaReferencia") + .HasColumnType("date") + .HasColumnName("dia_referencia"); + + b.Property("Lida") + .HasColumnType("boolean") + .HasColumnName("lida"); + + b.Property("LinkRelativo") + .HasColumnType("text") + .HasColumnName("link_relativo"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo"); + + b.Property("Titulo") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("titulo"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notificacoes"); + + b.HasIndex("DestinatarioContaId", "Lida", "CreatedAt") + .HasDatabaseName("ix_notificacoes_conta_lida_created"); + + b.HasIndex("DestinatarioContaId", "Tipo", "DiaReferencia") + .IsUnique() + .HasDatabaseName("ix_notificacoes_dedup") + .HasFilter("dia_referencia IS NOT NULL"); + + b.ToTable("notificacoes", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.OutboxEfeito", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChaveIdempotencia") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("chave_idempotencia"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("processado_em"); + + b.Property("ProximaTentativa") + .HasColumnType("timestamp with time zone") + .HasColumnName("proxima_tentativa"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Tentativas") + .HasColumnType("integer") + .HasColumnName("tentativas"); + + b.Property("Tipo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("tipo"); + + b.Property("UltimoErro") + .HasColumnType("text") + .HasColumnName("ultimo_erro"); + + b.HasKey("Id") + .HasName("pk_outbox_efeitos"); + + b.HasIndex("ChaveIdempotencia") + .IsUnique() + .HasDatabaseName("ix_outbox_efeitos_chave_idempotencia_unique"); + + b.HasIndex("Status", "ProximaTentativa") + .HasDatabaseName("ix_outbox_efeitos_status_proxima_tentativa"); + + b.ToTable("outbox_efeitos", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Pacote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Descricao") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("descricao"); + + b.Property("IsAtivo") + .HasColumnType("boolean") + .HasColumnName("is_ativo"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("nome"); + + b.Property("Preco") + .HasColumnType("numeric(10,2)") + .HasColumnName("preco"); + + b.Property("TreinadorId") + .HasColumnType("uuid") + .HasColumnName("treinador_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_pacotes"); + + b.HasIndex("TreinadorId") + .HasDatabaseName("ix_pacotes_treinador_id"); + + b.ToTable("pacotes", null, t => + { + t.HasCheckConstraint("ck_pacotes_preco_nao_negativo", "\"preco\" >= 0"); + }); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Pagamento", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssinaturaAlunoId") + .HasColumnType("uuid") + .HasColumnName("assinatura_aluno_id"); + + b.Property("ClientSecret") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("client_secret"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DataPagamento") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_pagamento"); + + b.Property("MetodoPagamento") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Pix") + .HasColumnName("metodo_pagamento"); + + b.Property("PixExpiracao") + .HasColumnType("timestamp with time zone") + .HasColumnName("pix_expiracao"); + + b.Property("PixQrCode") + .HasColumnType("text") + .HasColumnName("pix_qr_code"); + + b.Property("PixQrCodeUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("pix_qr_code_url"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("StripePaymentIntentId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("stripe_payment_intent_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Valor") + .HasColumnType("numeric(10,2)") + .HasColumnName("valor"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_pagamentos"); + + b.HasIndex("AssinaturaAlunoId") + .IsUnique() + .HasDatabaseName("ix_pagamentos_assinatura_aluno_id_pendente_unique") + .HasFilter("status = 'Pendente'"); + + b.HasIndex("StripePaymentIntentId") + .IsUnique() + .HasDatabaseName("ix_pagamentos_stripe_payment_intent_id"); + + b.HasIndex("AssinaturaAlunoId", "Status") + .HasDatabaseName("ix_pagamentos_assinatura_aluno_id_status"); + + b.ToTable("pagamentos", null, t => + { + t.HasCheckConstraint("ck_pagamentos_valor_nao_negativo", "\"valor\" >= 0"); + }); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.PagamentoTreinador", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssinaturaTreinadorId") + .HasColumnType("uuid") + .HasColumnName("assinatura_treinador_id"); + + b.Property("ClientSecret") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("client_secret"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DataPagamento") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_pagamento"); + + b.Property("Finalidade") + .IsRequired() + .HasColumnType("text") + .HasColumnName("finalidade"); + + b.Property("MetodoPagamento") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Pix") + .HasColumnName("metodo_pagamento"); + + b.Property("PixExpiracao") + .HasColumnType("timestamp with time zone") + .HasColumnName("pix_expiracao"); + + b.Property("PixQrCode") + .HasColumnType("text") + .HasColumnName("pix_qr_code"); + + b.Property("PixQrCodeUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("pix_qr_code_url"); + + b.Property("PlanoAlvoId") + .HasColumnType("uuid") + .HasColumnName("plano_alvo_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("StripePaymentIntentId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("stripe_payment_intent_id"); + + b.Property("TreinadorId") + .HasColumnType("uuid") + .HasColumnName("treinador_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Valor") + .HasColumnType("numeric(10,2)") + .HasColumnName("valor"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_pagamentos_treinador"); + + b.HasIndex("AssinaturaTreinadorId") + .IsUnique() + .HasDatabaseName("ix_pagamentos_treinador_assinatura_id_pendente_unique") + .HasFilter("status = 'Pendente'"); + + b.HasIndex("PlanoAlvoId") + .HasDatabaseName("ix_pagamentos_treinador_plano_alvo_id"); + + b.HasIndex("StripePaymentIntentId") + .IsUnique() + .HasDatabaseName("ix_pagamentos_treinador_stripe_payment_intent_id"); + + b.HasIndex("TreinadorId") + .HasDatabaseName("ix_pagamentos_treinador_treinador_id"); + + b.HasIndex("AssinaturaTreinadorId", "Status") + .HasDatabaseName("ix_pagamentos_treinador_assinatura_id_status"); + + b.ToTable("pagamentos_treinador", null, t => + { + t.HasCheckConstraint("ck_pagamentos_treinador_valor_nao_negativo", "\"valor\" >= 0"); + }); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.HasKey("Id") + .HasName("pk_password_reset_tokens"); + + b.HasIndex("ContaId") + .IsUnique() + .HasDatabaseName("ux_password_reset_tokens_conta_id_pendente") + .HasFilter("used_at IS NULL"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_password_reset_tokens_token_hash"); + + b.ToTable("password_reset_tokens", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.PlanoPlataforma", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Descricao") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("descricao"); + + b.Property("IsAtivo") + .HasColumnType("boolean") + .HasColumnName("is_ativo"); + + b.Property("MaxAlunos") + .HasColumnType("integer") + .HasColumnName("max_alunos"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("nome"); + + b.Property("Preco") + .HasColumnType("numeric(10,2)") + .HasColumnName("preco"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("tier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_planos_plataforma"); + + b.ToTable("planos_plataforma", null, t => + { + t.HasCheckConstraint("ck_planos_plataforma_max_alunos_positivo", "\"max_alunos\" > 0"); + + t.HasCheckConstraint("ck_planos_plataforma_preco_nao_negativo", "\"preco\" >= 0"); + }); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.ReconciliacaoStripeEstado", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("UltimoEventoReconciliadoUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("ultimo_evento_reconciliado_utc"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_reconciliacao_stripe_estado"); + + b.ToTable("reconciliacao_stripe_estado", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.RedefinicaoSenhaSegundoFator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AtualizadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("atualizado_em"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("JanelaInicio") + .HasColumnType("timestamp with time zone") + .HasColumnName("janela_inicio"); + + b.Property("Tentativas") + .HasColumnType("integer") + .HasColumnName("tentativas"); + + b.HasKey("Id") + .HasName("pk_redefinicao_senha_segundo_fator"); + + b.HasIndex("ContaId") + .IsUnique() + .HasDatabaseName("ix_redefinicao_senha_segundo_fator_conta_id"); + + b.ToTable("redefinicao_senha_segundo_fator", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("ExpiraEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_em"); + + b.Property("FamiliaId") + .HasColumnType("uuid") + .HasColumnName("familia_id"); + + b.Property("SubstituidoPorId") + .HasColumnType("uuid") + .HasColumnName("substituido_por_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UsadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("usado_em"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("FamiliaId") + .HasDatabaseName("ix_refresh_tokens_familia_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_hash"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.RefreshTokenFamily", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsolutoExpiraEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("absoluto_expira_em"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CriadaEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criada_em"); + + b.Property("MotivoRevogacao") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("motivo_revogacao"); + + b.Property("RevogadaEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("revogada_em"); + + b.Property("Rotulo") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("rotulo"); + + b.HasKey("Id") + .HasName("pk_refresh_token_families"); + + b.HasIndex("ContaId") + .HasDatabaseName("ix_refresh_token_families_conta_id"); + + b.HasIndex("RevogadaEm") + .HasDatabaseName("ix_refresh_token_families_revogada_em"); + + b.ToTable("refresh_token_families", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.SerieConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Carga") + .HasColumnType("numeric(10,2)") + .HasColumnName("carga"); + + b.Property("Descanso") + .HasColumnType("integer") + .HasColumnName("descanso"); + + b.Property("Descricao") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("descricao"); + + b.Property("Ordem") + .HasColumnType("integer") + .HasColumnName("ordem"); + + b.Property("Quantidade") + .HasColumnType("integer") + .HasColumnName("quantidade"); + + b.Property("RepeticoesMax") + .HasColumnType("integer") + .HasColumnName("repeticoes_max"); + + b.Property("RepeticoesMin") + .HasColumnType("integer") + .HasColumnName("repeticoes_min"); + + b.Property("TreinoExercicioId") + .HasColumnType("uuid") + .HasColumnName("treino_exercicio_id"); + + b.HasKey("Id") + .HasName("pk_treino_exercicio_series"); + + b.HasIndex("TreinoExercicioId") + .HasDatabaseName("ix_treino_exercicio_series_treino_exercicio_id"); + + b.ToTable("treino_exercicio_series", null, t => + { + t.HasCheckConstraint("ck_treino_exercicio_series_quantidade_positivo", "\"quantidade\" > 0"); + + t.HasCheckConstraint("ck_treino_exercicio_series_repeticoes_max_gte_min", "\"repeticoes_max\" IS NULL OR \"repeticoes_max\" >= \"repeticoes_min\""); + + t.HasCheckConstraint("ck_treino_exercicio_series_repeticoes_min_positivo", "\"repeticoes_min\" > 0"); + }); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.SystemUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("nome"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_system_users"); + + b.HasIndex("ContaId") + .HasDatabaseName("ix_system_users_conta_id"); + + b.ToTable("system_users", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.TokenRevogado", b => + { + b.Property("Jti") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("jti"); + + b.Property("ExpiraEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_em"); + + b.HasKey("Jti") + .HasName("pk_tokens_revogados"); + + b.HasIndex("ExpiraEm") + .HasDatabaseName("ix_tokens_revogados_expira_em"); + + b.ToTable("tokens_revogados", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Treinador", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlunosAcimaDoCapDesde") + .HasColumnType("timestamp with time zone") + .HasColumnName("alunos_acima_do_cap_desde"); + + b.Property("Anonimizado") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("anonimizado"); + + b.Property("AprovadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("aprovado_em"); + + b.Property("AprovadoPorId") + .HasColumnType("uuid") + .HasColumnName("aprovado_por_id"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ModoPagamentoAluno") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Plataforma") + .HasColumnName("modo_pagamento_aluno"); + + b.Property("ModoPagamentoAlunoAlteradoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("modo_pagamento_aluno_alterado_em"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("nome"); + + b.Property("PlanoCortesiaId") + .HasColumnType("uuid") + .HasColumnName("plano_cortesia_id"); + + b.Property("PlanoPlataformaId") + .HasColumnType("uuid") + .HasColumnName("plano_plataforma_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Telefone") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("telefone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_treinadores"); + + b.HasIndex("ContaId") + .IsUnique() + .HasDatabaseName("ix_treinadores_conta_id"); + + b.HasIndex("PlanoCortesiaId") + .HasDatabaseName("ix_treinadores_plano_cortesia_id"); + + b.HasIndex("PlanoPlataformaId") + .HasDatabaseName("ix_treinadores_plano_plataforma_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_treinadores_status"); + + b.ToTable("treinadores", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Treino", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DataFim") + .HasColumnType("date") + .HasColumnName("data_fim"); + + b.Property("DataInicio") + .HasColumnType("date") + .HasColumnName("data_inicio"); + + b.Property("Dificuldade") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Iniciante") + .HasColumnName("dificuldade"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("nome"); + + b.Property("Objetivo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("objetivo"); + + b.Property("TreinadorId") + .HasColumnType("uuid") + .HasColumnName("treinador_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_treinos"); + + b.HasIndex("TreinadorId") + .HasDatabaseName("ix_treinos_treinador_id"); + + b.ToTable("treinos", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.TreinoAluno", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlunoId") + .HasColumnType("uuid") + .HasColumnName("aluno_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TreinoId") + .HasColumnType("uuid") + .HasColumnName("treino_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_treino_alunos"); + + b.HasIndex("AlunoId") + .HasDatabaseName("ix_treino_alunos_aluno_id"); + + b.HasIndex("TreinoId") + .IsUnique() + .HasDatabaseName("ix_treino_alunos_treino_id") + .HasFilter("status = 'Ativo'"); + + b.ToTable("treino_alunos", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.TreinoExercicio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExercicioId") + .HasColumnType("uuid") + .HasColumnName("exercicio_id"); + + b.Property("Observacao") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("observacao"); + + b.Property("Ordem") + .HasColumnType("integer") + .HasColumnName("ordem"); + + b.Property("TreinoId") + .HasColumnType("uuid") + .HasColumnName("treino_id"); + + b.HasKey("Id") + .HasName("pk_treino_exercicios"); + + b.HasIndex("ExercicioId") + .HasDatabaseName("ix_treino_exercicios_exercicio_id"); + + b.HasIndex("TreinoId") + .HasDatabaseName("ix_treino_exercicios_treino_id"); + + b.ToTable("treino_exercicios", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.TrocaEmailToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("ExpiraEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_em"); + + b.Property("NovoEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("novo_email"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UsadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("usado_em"); + + b.HasKey("Id") + .HasName("pk_troca_email_tokens"); + + b.HasIndex("ContaId") + .HasDatabaseName("ix_troca_email_tokens_conta_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_troca_email_tokens_token_hash"); + + b.ToTable("troca_email_tokens", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.TrustedDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContaId") + .HasColumnType("uuid") + .HasColumnName("conta_id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("ExpiraEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_em"); + + b.Property("RevogadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("revogado_em"); + + b.Property("Rotulo") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("rotulo"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UltimoUsoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("ultimo_uso_em"); + + b.HasKey("Id") + .HasName("pk_trusted_devices"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_trusted_devices_token_hash"); + + b.ToTable("trusted_devices", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.VinculoTreinadorAluno", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlunoId") + .HasColumnType("uuid") + .HasColumnName("aluno_id"); + + b.Property("AprovadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("aprovado_em"); + + b.Property("AprovadoPorId") + .HasColumnType("uuid") + .HasColumnName("aprovado_por_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DataFim") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_fim"); + + b.Property("DataInicio") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_inicio"); + + b.Property("PacoteId") + .HasColumnType("uuid") + .HasColumnName("pacote_id"); + + b.Property("PreservarNoLimite") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("preservar_no_limite"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TreinadorId") + .HasColumnType("uuid") + .HasColumnName("treinador_id"); + + b.HasKey("Id") + .HasName("pk_vinculos_treinador_aluno"); + + b.HasIndex("AlunoId") + .HasDatabaseName("ix_vinculos_treinador_aluno_aluno_id"); + + b.HasIndex("PacoteId") + .HasDatabaseName("ix_vinculos_treinador_aluno_pacote_id"); + + b.HasIndex("TreinadorId", "AlunoId") + .HasDatabaseName("ix_vinculos_treinador_aluno_treinador_id_aluno_id"); + + b.HasIndex("TreinadorId", "Status") + .HasDatabaseName("ix_vinculos_treinador_aluno_treinador_id_status"); + + b.ToTable("vinculos_treinador_aluno", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.WhatsAppDeliveryLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetaMessageId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("meta_message_id"); + + b.Property("OcorridoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("ocorrido_em"); + + b.Property("RecipientPhoneHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("recipient_phone_hash"); + + b.HasKey("Id") + .HasName("pk_whatsapp_delivery_logs"); + + b.HasIndex("EventType") + .HasDatabaseName("ix_whatsapp_delivery_logs_event_type"); + + b.HasIndex("MetaMessageId", "EventType") + .IsUnique() + .HasDatabaseName("ix_whatsapp_delivery_logs_meta_message_id_event_type"); + + b.ToTable("whatsapp_delivery_logs", (string)null); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Aluno", b => + { + b.HasOne("forzion.tech.Domain.Entities.Conta", null) + .WithMany() + .HasForeignKey("ContaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_alunos_contas_conta_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.AssinaturaAluno", b => + { + b.HasOne("forzion.tech.Domain.Entities.Aluno", null) + .WithMany() + .HasForeignKey("AlunoId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_assinaturas_aluno_alunos_aluno_id"); + + b.HasOne("forzion.tech.Domain.Entities.Pacote", null) + .WithMany() + .HasForeignKey("PacoteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_assinaturas_aluno_pacotes_pacote_id"); + + b.HasOne("forzion.tech.Domain.Entities.Treinador", null) + .WithMany() + .HasForeignKey("TreinadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_assinaturas_aluno_treinadores_treinador_id"); + + b.HasOne("forzion.tech.Domain.Entities.VinculoTreinadorAluno", null) + .WithMany() + .HasForeignKey("VinculoId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_assinaturas_aluno_vinculos_treinador_aluno_vinculo_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.AssinaturaTreinador", b => + { + b.HasOne("forzion.tech.Domain.Entities.PlanoPlataforma", null) + .WithMany() + .HasForeignKey("PlanoPlataformaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_assinaturas_treinador_planos_plataforma_plano_plataforma_id"); + + b.HasOne("forzion.tech.Domain.Entities.PlanoPlataforma", null) + .WithMany() + .HasForeignKey("PlanoPlataformaIdAgendado") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_assinaturas_treinador_planos_plataforma_plano_plataforma_id1"); + + b.HasOne("forzion.tech.Domain.Entities.Treinador", null) + .WithMany() + .HasForeignKey("TreinadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_assinaturas_treinador_treinadores_treinador_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.ContaRecebimento", b => + { + b.HasOne("forzion.tech.Domain.Entities.Treinador", null) + .WithMany() + .HasForeignKey("TreinadorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_conta_recebimento_treinadores_treinador_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.ExecucaoExercicio", b => + { + b.HasOne("forzion.tech.Domain.Entities.ExecucaoTreino", null) + .WithMany("Exercicios") + .HasForeignKey("ExecucaoTreinoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_execucoes_exercicio_execucoes_treino_execucao_treino_id"); + + b.HasOne("forzion.tech.Domain.Entities.TreinoExercicio", null) + .WithMany() + .HasForeignKey("TreinoExercicioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_execucoes_exercicio_treino_exercicios_treino_exercicio_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.ExecucaoTreino", b => + { + b.HasOne("forzion.tech.Domain.Entities.Aluno", null) + .WithMany() + .HasForeignKey("AlunoId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_execucoes_treino_alunos_aluno_id"); + + b.HasOne("forzion.tech.Domain.Entities.Treino", null) + .WithMany() + .HasForeignKey("TreinoId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_execucoes_treino_treinos_treino_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Exercicio", b => + { + b.HasOne("forzion.tech.Domain.Entities.GrupoMuscular", null) + .WithMany() + .HasForeignKey("GrupoMuscularId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_exercicios_grupos_musculares_grupo_muscular_id"); + + b.HasOne("forzion.tech.Domain.Entities.Treinador", null) + .WithMany() + .HasForeignKey("TreinadorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exercicios_treinadores_treinador_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.MensagemSuporte", b => + { + b.HasOne("forzion.tech.Domain.Entities.Conta", null) + .WithMany() + .HasForeignKey("ContaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_mensagens_suporte_contas_conta_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.NotaFiscal", b => + { + b.HasOne("forzion.tech.Domain.Entities.PagamentoTreinador", null) + .WithMany() + .HasForeignKey("PagamentoTreinadorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_notas_fiscais_pagamentos_treinador_pagamento_treinador_id"); + + b.HasOne("forzion.tech.Domain.Entities.Treinador", null) + .WithMany() + .HasForeignKey("TreinadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_notas_fiscais_treinadores_treinador_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Notificacao", b => + { + b.HasOne("forzion.tech.Domain.Entities.Conta", null) + .WithMany() + .HasForeignKey("DestinatarioContaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notificacoes_contas_destinatario_conta_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Pacote", b => + { + b.HasOne("forzion.tech.Domain.Entities.Treinador", null) + .WithMany() + .HasForeignKey("TreinadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_pacotes_treinadores_treinador_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Pagamento", b => + { + b.HasOne("forzion.tech.Domain.Entities.AssinaturaAluno", null) + .WithMany() + .HasForeignKey("AssinaturaAlunoId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_pagamentos_assinaturas_aluno_assinatura_aluno_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.PagamentoTreinador", b => + { + b.HasOne("forzion.tech.Domain.Entities.AssinaturaTreinador", null) + .WithMany() + .HasForeignKey("AssinaturaTreinadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_pagamentos_treinador_assinaturas_treinador_assinatura_trein"); + + b.HasOne("forzion.tech.Domain.Entities.PlanoPlataforma", null) + .WithMany() + .HasForeignKey("PlanoAlvoId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_pagamentos_treinador_planos_plataforma_plano_alvo_id"); + + b.HasOne("forzion.tech.Domain.Entities.Treinador", null) + .WithMany() + .HasForeignKey("TreinadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_pagamentos_treinador_treinadores_treinador_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.RefreshToken", b => + { + b.HasOne("forzion.tech.Domain.Entities.RefreshTokenFamily", null) + .WithMany() + .HasForeignKey("FamiliaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_refresh_token_families_familia_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.SerieConfig", b => + { + b.HasOne("forzion.tech.Domain.Entities.TreinoExercicio", null) + .WithMany("Series") + .HasForeignKey("TreinoExercicioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_treino_exercicio_series_treino_exercicios_treino_exercicio_"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.SystemUser", b => + { + b.HasOne("forzion.tech.Domain.Entities.Conta", null) + .WithMany() + .HasForeignKey("ContaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_system_users_contas_conta_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Treinador", b => + { + b.HasOne("forzion.tech.Domain.Entities.Conta", null) + .WithMany() + .HasForeignKey("ContaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_treinadores_contas_conta_id"); + + b.HasOne("forzion.tech.Domain.Entities.PlanoPlataforma", null) + .WithMany() + .HasForeignKey("PlanoCortesiaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_treinadores_planos_plataforma_plano_cortesia_id"); + + b.HasOne("forzion.tech.Domain.Entities.PlanoPlataforma", null) + .WithMany() + .HasForeignKey("PlanoPlataformaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_treinadores_planos_plataforma_plano_plataforma_id"); + + b.OwnsOne("forzion.tech.Domain.ValueObjects.DadosFiscais", "DadosFiscais", b1 => + { + b1.Property("TreinadorId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Documento") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("dados_fiscais_documento"); + + b1.Property("InscricaoMunicipal") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("dados_fiscais_inscricao_municipal"); + + b1.Property("RazaoSocial") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("dados_fiscais_razao_social"); + + b1.Property("TipoDocumento") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("dados_fiscais_tipo_documento"); + + b1.HasKey("TreinadorId"); + + b1.ToTable("treinadores"); + + b1.WithOwner() + .HasForeignKey("TreinadorId") + .HasConstraintName("fk_treinadores_treinadores_id"); + + b1.OwnsOne("forzion.tech.Domain.ValueObjects.EnderecoFiscal", "Endereco", b2 => + { + b2.Property("DadosFiscaisTreinadorId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b2.Property("Bairro") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("dados_fiscais_endereco_bairro"); + + b2.Property("Cep") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("dados_fiscais_endereco_cep"); + + b2.Property("CodigoMunicipioIbge") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("dados_fiscais_endereco_codigo_municipio_ibge"); + + b2.Property("Complemento") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("dados_fiscais_endereco_complemento"); + + b2.Property("Logradouro") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("dados_fiscais_endereco_logradouro"); + + b2.Property("Numero") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("dados_fiscais_endereco_numero"); + + b2.Property("Uf") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("dados_fiscais_endereco_uf"); + + b2.HasKey("DadosFiscaisTreinadorId"); + + b2.ToTable("treinadores"); + + b2.WithOwner() + .HasForeignKey("DadosFiscaisTreinadorId") + .HasConstraintName("fk_treinadores_treinadores_id"); + }); + + b1.Navigation("Endereco") + .IsRequired(); + }); + + b.Navigation("DadosFiscais"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Treino", b => + { + b.HasOne("forzion.tech.Domain.Entities.Treinador", null) + .WithMany() + .HasForeignKey("TreinadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_treinos_treinadores_treinador_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.TreinoAluno", b => + { + b.HasOne("forzion.tech.Domain.Entities.Aluno", null) + .WithMany() + .HasForeignKey("AlunoId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_treino_alunos_alunos_aluno_id"); + + b.HasOne("forzion.tech.Domain.Entities.Treino", null) + .WithMany() + .HasForeignKey("TreinoId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_treino_alunos_treinos_treino_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.TreinoExercicio", b => + { + b.HasOne("forzion.tech.Domain.Entities.Exercicio", null) + .WithMany() + .HasForeignKey("ExercicioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_treino_exercicios_exercicios_exercicio_id"); + + b.HasOne("forzion.tech.Domain.Entities.Treino", null) + .WithMany("Exercicios") + .HasForeignKey("TreinoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_treino_exercicios_treinos_treino_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.VinculoTreinadorAluno", b => + { + b.HasOne("forzion.tech.Domain.Entities.Aluno", null) + .WithMany() + .HasForeignKey("AlunoId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_vinculos_treinador_aluno_alunos_aluno_id"); + + b.HasOne("forzion.tech.Domain.Entities.Pacote", null) + .WithMany() + .HasForeignKey("PacoteId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vinculos_treinador_aluno_pacotes_pacote_id"); + + b.HasOne("forzion.tech.Domain.Entities.Treinador", null) + .WithMany() + .HasForeignKey("TreinadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_vinculos_treinador_aluno_treinadores_treinador_id"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.ExecucaoTreino", b => + { + b.Navigation("Exercicios"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.Treino", b => + { + b.Navigation("Exercicios"); + }); + + modelBuilder.Entity("forzion.tech.Domain.Entities.TreinoExercicio", b => + { + b.Navigation("Series"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/forzion.tech.Infrastructure/Migrations/20260801201209_InativarPlanoProPlus.cs b/forzion.tech.Infrastructure/Migrations/20260801201209_InativarPlanoProPlus.cs new file mode 100644 index 00000000..6510ca55 --- /dev/null +++ b/forzion.tech.Infrastructure/Migrations/20260801201209_InativarPlanoProPlus.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace forzion.tech.Infrastructure.Migrations +{ + /// + public partial class InativarPlanoProPlus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("UPDATE planos_plataforma SET is_ativo = false, updated_at = now() WHERE tier = 'ProPlus' AND is_ativo = true;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("UPDATE planos_plataforma SET is_ativo = true, updated_at = now() WHERE tier = 'ProPlus' AND is_ativo = false;"); + } + } +} diff --git a/forzion.tech.Infrastructure/Seed/DataSeeder.cs b/forzion.tech.Infrastructure/Seed/DataSeeder.cs index 07679245..60642ca6 100644 --- a/forzion.tech.Infrastructure/Seed/DataSeeder.cs +++ b/forzion.tech.Infrastructure/Seed/DataSeeder.cs @@ -132,7 +132,7 @@ private static readonly (TierPlanoEnum Tier, string Nome, int MaxAlunos, decimal (TierPlanoEnum.Free, "Free", 10, 0m, true, "Ideal para começar e testar sem compromisso. Acesso à plataforma para até 10 alunos."), (TierPlanoEnum.Basic, "Basic", 25, 50m, true, "Acesso completo à plataforma de treinos. R$2 por aluno/mês na lotação."), (TierPlanoEnum.Pro, "Pro", 50, 100m, true, "Tudo do Basic + notificações por e-mail que mantêm seus alunos engajados entre as sessões."), - (TierPlanoEnum.ProPlus, "Pro Plus", 100, 200m, true, "Tudo do Pro + WhatsApp integrado: seus alunos recebem tudo onde já estão."), + (TierPlanoEnum.ProPlus, "Pro Plus", 100, 200m, false, "Tudo do Pro + WhatsApp integrado: seus alunos recebem tudo onde já estão."), (TierPlanoEnum.Elite, "Elite", 300, 500m, false, "O plano mais completo: tudo do Pro Plus somado a IA para personalizar e otimizar cada treino."), ]; diff --git a/forzion.tech.Tests/Api/Endpoints/WebhookEndpointsTests.cs b/forzion.tech.Tests/Api/Endpoints/WebhookEndpointsTests.cs index 748d5210..0353a175 100644 --- a/forzion.tech.Tests/Api/Endpoints/WebhookEndpointsTests.cs +++ b/forzion.tech.Tests/Api/Endpoints/WebhookEndpointsTests.cs @@ -154,14 +154,57 @@ public async Task Post_WebhookWhatsApp_Invalido_NaoEcoaErrorMessage() .Setup(h => h.HandleAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Failure(Error.Business("webhook_whatsapp.assinatura_invalida", "Assinatura inválida."))); + var client = _factory.WithWebHostBuilder(builder => + builder.UseSetting("WhatsApp:Habilitado", "true")) + .CreateClient(); + var content = new StringContent("{}", Encoding.UTF8, "application/json"); - var response = await _factory.CreateClient().PostAsync("/webhooks/whatsapp", content); + var response = await client.PostAsync("/webhooks/whatsapp", content); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); var problem = await response.Content.ReadFromJsonAsync(); problem.GetProperty("detail").GetString().Should().Be("Webhook inválido."); } + // --- POST /webhooks/whatsapp (kill-switch WhatsApp:Habilitado) --- + + [Fact] + public async Task Post_WebhookWhatsApp_Desabilitado_Retorna200SemInvocarHandler() + { + // Mock é compartilhado via IClassFixture — limpa histórico de outros testes da classe. + _factory.ProcessarWebhookWhatsAppHandlerMock.Invocations.Clear(); + + // Factory base não seta WhatsApp:Habilitado — default false, igual ao ambiente real. + var content = new StringContent("{}", Encoding.UTF8, "application/json"); + var response = await _factory.CreateClient().PostAsync("/webhooks/whatsapp", content); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + _factory.ProcessarWebhookWhatsAppHandlerMock.Verify( + h => h.HandleAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Post_WebhookWhatsApp_Habilitado_InvocaHandlerComoAntes() + { + _factory.ProcessarWebhookWhatsAppHandlerMock.Invocations.Clear(); + _factory.ProcessarWebhookWhatsAppHandlerMock + .Setup(h => h.HandleAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success()); + + var client = _factory.WithWebHostBuilder(builder => + builder.UseSetting("WhatsApp:Habilitado", "true")) + .CreateClient(); + + var content = new StringContent("{}", Encoding.UTF8, "application/json"); + var response = await client.PostAsync("/webhooks/whatsapp", content); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + _factory.ProcessarWebhookWhatsAppHandlerMock.Verify( + h => h.HandleAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + // --- GET /webhooks/whatsapp (Meta verification handshake) --- [Fact] @@ -205,6 +248,23 @@ public async Task Get_WebhookWhatsApp_ModeErrado_Retorna403() response.StatusCode.Should().Be(HttpStatusCode.Forbidden); } + [Fact] + public async Task Get_WebhookWhatsApp_ComFlagHabilitada_HandshakeInalterado() + { + // Complementa Get_WebhookWhatsApp_TokenCorreto_Retorna200ComChallenge (roda com a + // factory base, flag ausente/false): prova que o GET também é idêntico com flag=true. + var client = _factory.WithWebHostBuilder(builder => + builder.UseSetting("WhatsApp:Habilitado", "true")) + .CreateClient(); + + var response = await client + .GetAsync($"/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token={WebhookWebFactory.VerifyToken}&hub.challenge=abc123"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Be("abc123"); + } + // --- WebApplicationFactory --- public class WebhookWebFactory : WebApplicationFactory diff --git a/forzion.tech.Tests/Infrastructure/DependencyInjection/WhatsAppKillSwitchTests.cs b/forzion.tech.Tests/Infrastructure/DependencyInjection/WhatsAppKillSwitchTests.cs new file mode 100644 index 00000000..7734d3a3 --- /dev/null +++ b/forzion.tech.Tests/Infrastructure/DependencyInjection/WhatsAppKillSwitchTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using forzion.tech.Application.Interfaces; +using forzion.tech.Infrastructure.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +namespace forzion.tech.Tests.Infrastructure.DependencyInjection; + +public class WhatsAppKillSwitchTests +{ + private static bool WhatsAppHabilitado(IDictionary config) + { + var (services, configuration) = InfraHarness.Montar(config); + services.AddInfrastructure(configuration, InfraHarness.Env("Development")); + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + return scope.ServiceProvider.GetRequiredService().Habilitado; + } + + [Fact] + public void Desabilitado_ComCredenciaisValidas_RegistraNull() + { + WhatsAppHabilitado(new Dictionary + { + ["WhatsApp:Habilitado"] = "false", + ["WhatsApp:PhoneNumberId"] = "123456", + ["WhatsApp:AccessToken"] = "token-valido" + }).Should().BeFalse(); + } + + [Fact] + public void Habilitado_ComCredenciaisValidas_RegistraMeta() + { + WhatsAppHabilitado(new Dictionary + { + ["WhatsApp:Habilitado"] = "true", + ["WhatsApp:PhoneNumberId"] = "123456", + ["WhatsApp:AccessToken"] = "token-valido" + }).Should().BeTrue(); + } + + [Fact] + public void DesabilitadoOuAusente_SemCredenciais_RegistraNull() + { + WhatsAppHabilitado(new Dictionary()).Should().BeFalse(); + } + + [Fact] + public void Habilitado_SemCredenciais_RegistraNull() + { + WhatsAppHabilitado(new Dictionary + { + ["WhatsApp:Habilitado"] = "true" + }).Should().BeFalse(); + } +} diff --git a/forzion.tech.Tests/Infrastructure/Health/WhatsAppHealthCheckTests.cs b/forzion.tech.Tests/Infrastructure/Health/WhatsAppHealthCheckTests.cs new file mode 100644 index 00000000..044ed263 --- /dev/null +++ b/forzion.tech.Tests/Infrastructure/Health/WhatsAppHealthCheckTests.cs @@ -0,0 +1,113 @@ +using System.Net; +using FluentAssertions; +using forzion.tech.Infrastructure.Health; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using Moq.Protected; + +namespace forzion.tech.Tests.Infrastructure.Health; + +public class WhatsAppHealthCheckTests +{ + private static readonly HealthCheckContext FakeCtx = new() + { + Registration = new HealthCheckRegistration("whatsapp", _ => null!, null, null) + }; + + private static (WhatsAppHealthCheck check, Mock handler) Criar( + IDictionary config, HttpStatusCode statusCode = HttpStatusCode.OK) + { + var handlerMock = new Mock(MockBehavior.Strict); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(statusCode)); + + var factoryMock = new Mock(); + factoryMock.Setup(f => f.CreateClient(string.Empty)).Returns(new HttpClient(handlerMock.Object)); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(config).Build(); + + return (new WhatsAppHealthCheck(factoryMock.Object, configuration), handlerMock); + } + + [Fact] + public async Task CheckHealthAsync_Desabilitado_ComCredenciais_RetornaHealthy_SemChamadaHttp() + { + var (check, handler) = Criar(new Dictionary + { + ["WhatsApp:Habilitado"] = "false", + ["WhatsApp:PhoneNumberId"] = "123456", + ["WhatsApp:AccessToken"] = "token-valido" + }); + + var result = await check.CheckHealthAsync(FakeCtx); + + result.Status.Should().Be(HealthStatus.Healthy); + result.Description.Should().Contain("desativado temporariamente"); + handler.Protected().Verify( + "SendAsync", Times.Never(), ItExpr.IsAny(), ItExpr.IsAny()); + } + + [Fact] + public async Task CheckHealthAsync_DesabilitadoAusente_SemCredenciais_RetornaHealthy_SemChamadaHttp() + { + var (check, handler) = Criar(new Dictionary()); + + var result = await check.CheckHealthAsync(FakeCtx); + + result.Status.Should().Be(HealthStatus.Healthy); + handler.Protected().Verify( + "SendAsync", Times.Never(), ItExpr.IsAny(), ItExpr.IsAny()); + } + + [Fact] + public async Task CheckHealthAsync_Habilitado_Responde200_RetornaHealthy() + { + var (check, _) = Criar(new Dictionary + { + ["WhatsApp:Habilitado"] = "true", + ["WhatsApp:PhoneNumberId"] = "123456", + ["WhatsApp:AccessToken"] = "token-valido" + }, HttpStatusCode.OK); + + var result = await check.CheckHealthAsync(FakeCtx); + + result.Status.Should().Be(HealthStatus.Healthy); + } + + [Fact] + public async Task CheckHealthAsync_Habilitado_Responde500_RetornaDegraded() + { + var (check, _) = Criar(new Dictionary + { + ["WhatsApp:Habilitado"] = "true", + ["WhatsApp:PhoneNumberId"] = "123456", + ["WhatsApp:AccessToken"] = "token-valido" + }, HttpStatusCode.InternalServerError); + + var result = await check.CheckHealthAsync(FakeCtx); + + result.Status.Should().Be(HealthStatus.Degraded); + } + + [Fact] + public async Task CheckHealthAsync_Habilitado_SemCredenciais_RetornaHealthy_SemChamadaHttp() + { + var (check, handler) = Criar(new Dictionary + { + ["WhatsApp:Habilitado"] = "true" + }); + + var result = await check.CheckHealthAsync(FakeCtx); + + result.Status.Should().Be(HealthStatus.Healthy); + result.Description.Should().Contain("não configurado"); + handler.Protected().Verify( + "SendAsync", Times.Never(), ItExpr.IsAny(), ItExpr.IsAny()); + } +} diff --git a/forzion.tech.Tests/Infrastructure/Seed/DataSeederPlanosTests.cs b/forzion.tech.Tests/Infrastructure/Seed/DataSeederPlanosTests.cs index 68ae88a5..b767b34b 100644 --- a/forzion.tech.Tests/Infrastructure/Seed/DataSeederPlanosTests.cs +++ b/forzion.tech.Tests/Infrastructure/Seed/DataSeederPlanosTests.cs @@ -27,14 +27,26 @@ public async Task SeedAsync_PlanoElite_CriadoInativo() } [Fact] - public async Task SeedAsync_PlanosNaoElite_PermanecemAtivos() + public async Task SeedAsync_PlanoProPlus_CriadoInativo() { var connectionString = await SeedAsync(); await using var ctx = fixture.CreateContext(connectionString); - var demais = await ctx.PlanosPlataforma.Where(p => p.Tier != TierPlano.Elite).ToListAsync(); - demais.Should().NotBeEmpty(); - demais.Should().OnlyContain(p => p.IsAtivo); + var proPlus = await ctx.PlanosPlataforma.SingleAsync(p => p.Tier == TierPlano.ProPlus); + proPlus.IsAtivo.Should().BeFalse(); + } + + [Fact] + public async Task SeedAsync_PlanosVendaveis_PermanecemAtivos() + { + var connectionString = await SeedAsync(); + + await using var ctx = fixture.CreateContext(connectionString); + var vendaveis = await ctx.PlanosPlataforma + .Where(p => p.Tier != TierPlano.Elite && p.Tier != TierPlano.ProPlus) + .ToListAsync(); + vendaveis.Should().NotBeEmpty(); + vendaveis.Should().OnlyContain(p => p.IsAtivo); } [Fact] diff --git a/frontend/src/app/(treinador)/treinador/plano/__tests__/plano-tier-efetivo.test.tsx b/frontend/src/app/(treinador)/treinador/plano/__tests__/plano-tier-efetivo.test.tsx index 5ecb93c4..20ea34dc 100644 --- a/frontend/src/app/(treinador)/treinador/plano/__tests__/plano-tier-efetivo.test.tsx +++ b/frontend/src/app/(treinador)/treinador/plano/__tests__/plano-tier-efetivo.test.tsx @@ -7,6 +7,13 @@ vi.mock("next/navigation", () => ({ useParams: () => ({}), })); +const featureFlagsState = vi.hoisted(() => ({ whatsappTemporariamenteIndisponivel: true })); +vi.mock("@/lib/config/feature-flags", () => ({ + get WHATSAPP_TEMPORARIAMENTE_INDISPONIVEL() { + return featureFlagsState.whatsappTemporariamenteIndisponivel; + }, +})); + vi.mock("@/lib/api/pagamento", () => ({ pagamentoApi: { obterAssinaturaTreinador: vi.fn(), @@ -85,6 +92,7 @@ function mockOk(planoOverrides: Partial = { beforeEach(() => { vi.clearAllMocks(); + featureFlagsState.whatsappTemporariamenteIndisponivel = true; }); describe("PlanoTreinadorPage — badge tier efetivo (FE-01)", () => { @@ -108,6 +116,12 @@ describe("PlanoTreinadorPage — badge tier efetivo (FE-01)", () => { }); describe("PlanoTreinadorPage — gating de canais por tier efetivo (FE-05)", () => { + // Comportamento pré-existente do chip (function-of-tier puro), exercitado com a + // flag WA-05 desligada — ver describe "kill-switch" abaixo para a flag ligada. + beforeEach(() => { + featureFlagsState.whatsappTemporariamenteIndisponivel = false; + }); + it("tier efetivo Free: e-mail e whatsapp indisponíveis", async () => { mockOk({ tierEfetivo: "Free" }); render(); @@ -132,3 +146,24 @@ describe("PlanoTreinadorPage — gating de canais por tier efetivo (FE-05)", () expect(screen.getByText(/WhatsApp — disponível/)).toBeInTheDocument(); }); }); + +describe("PlanoTreinadorPage — kill-switch WhatsApp temporariamente indisponível (WA-05)", () => { + it("flag ligada + tier efetivo ProPlus (legado): chip mostra indisponibilidade temporária, não tier", async () => { + featureFlagsState.whatsappTemporariamenteIndisponivel = true; + mockOk({ tierEfetivo: "ProPlus" }); + render(); + + expect(await screen.findByText("WhatsApp — temporariamente indisponível")).toBeInTheDocument(); + expect(screen.queryByText(/WhatsApp — disponível$/)).not.toBeInTheDocument(); + expect(screen.queryByText(/WhatsApp — indisponível$/)).not.toBeInTheDocument(); + }); + + it("flag desligada: chip volta ao comportamento function-of-tier (regressão)", async () => { + featureFlagsState.whatsappTemporariamenteIndisponivel = false; + mockOk({ tierEfetivo: "ProPlus" }); + render(); + + expect(await screen.findByText(/WhatsApp — disponível/)).toBeInTheDocument(); + expect(screen.queryByText("WhatsApp — temporariamente indisponível")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/(treinador)/treinador/plano/page.tsx b/frontend/src/app/(treinador)/treinador/plano/page.tsx index aba387a5..37e6dcc5 100644 --- a/frontend/src/app/(treinador)/treinador/plano/page.tsx +++ b/frontend/src/app/(treinador)/treinador/plano/page.tsx @@ -14,6 +14,7 @@ import { DialogTitle, Divider, Stack, + Tooltip, Typography, } from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; @@ -31,6 +32,7 @@ import LoadingSpinner from "@/components/ui/LoadingSpinner"; import TierEfetivoBadge from "@/components/treinador/TierEfetivoBadge"; import { formatarBRL } from "@/lib/utils/formatting"; import { permiteEmailEngajamento, permiteWhatsapp, type TierEfetivoStatus } from "@/lib/utils/tier"; +import { WHATSAPP_TEMPORARIAMENTE_INDISPONIVEL } from "@/lib/config/feature-flags"; import type { AssinaturaTreinadorResponse, ContratarPlanoTreinadorResponse, @@ -326,12 +328,23 @@ export default function PlanoTreinadorPage() { color={permiteEmailEngajamento(dashboardPlano.tierEfetivo) ? "success" : "default"} variant={permiteEmailEngajamento(dashboardPlano.tierEfetivo) ? "filled" : "outlined"} /> - + {WHATSAPP_TEMPORARIAMENTE_INDISPONIVEL ? ( + + + + ) : ( + + )} diff --git a/frontend/src/lib/config/feature-flags.ts b/frontend/src/lib/config/feature-flags.ts new file mode 100644 index 00000000..5f5300cf --- /dev/null +++ b/frontend/src/lib/config/feature-flags.ts @@ -0,0 +1,2 @@ +/** Kill-switch temporário — Meta Business Manager bloqueou a verificação de negócio. */ +export const WHATSAPP_TEMPORARIAMENTE_INDISPONIVEL = true; diff --git a/specs/specification-whatsapp.md b/specs/specification-whatsapp.md index b4dab21d..3874b3ad 100644 --- a/specs/specification-whatsapp.md +++ b/specs/specification-whatsapp.md @@ -17,12 +17,13 @@ DOC PARA AGENTES. Fonte de verdade das notificações WhatsApp (Meta Cloud API). - `MetaWhatsAppCloudNotifier` — `Habilitado=true`. POST `messages` (Bearer). `SendTemplateAsync` monta payload `type:template` (components body). `SendAsync` monta `type:text`. Telefone normalizado por `PhoneNumberNormalizer` (null → skip + warn). Falha HTTP/exceção = LOG only, sem rethrow. - `NullWhatsAppNotifier` — `Habilitado=false`; no-op (warn no ctor, debug por mensagem). - `EnvironmentWhatsAppDecorator` — embrulha o real. `Habilitado` delega. Em não-prod (`WhatsAppSettings.MarcarComoTeste`) aplica redirect/allowlist de telefone; em prod passthrough. -- GATE (DI, `InfrastructureExtensions`): `WhatsApp:PhoneNumberId` E `WhatsApp:AccessToken` não-vazios → `MetaWhatsAppCloudNotifier` (typed HttpClient, base `graph.facebook.com/{ApiVersion}/{PhoneNumberId}/`, timeout 15s); senão → `NullWhatsAppNotifier`. **Sempre** embrulhado no `EnvironmentWhatsAppDecorator` (helper `EnvolverComWhatsAppDecorator`, análogo ao e-mail). +- GATE (DI, `InfrastructureExtensions`): `WhatsApp:Habilitado=true` **E** `WhatsApp:PhoneNumberId`/`WhatsApp:AccessToken` não-vazios → `MetaWhatsAppCloudNotifier` (typed HttpClient, base `graph.facebook.com/{ApiVersion}/{PhoneNumberId}/`, timeout 15s); qualquer outra combinação (inclusive `Habilitado=false` com credenciais válidas) → `NullWhatsAppNotifier`. **Sempre** embrulhado no `EnvironmentWhatsAppDecorator` (helper `EnvolverComWhatsAppDecorator`, análogo ao e-mail). **Kill-switch explícito** (`WhatsApp:Habilitado`, default `false`): a flag é checada ANTES da credencial — defense-in-depth vs configurar credencial cedo demais durante um processo de verificação Meta em andamento. Mesmo gate central protege webhook POST (`WebhookEndpoints`) e `WhatsAppHealthCheck`, que checam a flag independentemente (ver §DESATIVAÇÃO TEMPORÁRIA). - Handlers de domain event registrados manual no DI; despachados no `UnitOfWork.CommitAsync`. ## CONFIG (chaves) | Chave | Onde lida | Função | Ausente | |-------|-----------|--------|---------| +| `WhatsApp:Habilitado` | `InfrastructureExtensions` (gate DI) + `WebhookEndpoints` (POST) + `WhatsAppHealthCheck` | kill-switch explícito; `false` força Null/no-op/Healthy independente de credencial | → `false` (bind bool sem exception) | | `WhatsApp:PhoneNumberId` | `InfrastructureExtensions` (gate) | seleciona Meta real; compõe BaseAddress | → Null | | `WhatsApp:AccessToken` | idem | Bearer token Meta | → Null | | `WhatsApp:ApiVersion` | idem | versão Graph API | default `v21.0` | @@ -34,7 +35,7 @@ DOC PARA AGENTES. Fonte de verdade das notificações WhatsApp (Meta Cloud API). | `App:FrontendBaseUrl` | `AppSettings` | base dos links nas mensagens | default vazio | - `WhatsAppSettings` (Application/Settings) bind seção `WhatsApp`. Defaults prod-safe. -- Deploy: env vars no compose. `docker-compose.homolog.yml` mapeia `WhatsApp__*` ← `${WHATSAPP_*}` (PhoneNumberId, AccessToken, ApiVersion, AppSecret, WebhookVerifyToken, MarcarComoTeste [default `true` em hmg], RedirecionarDestinatariosPara, AllowlistTelefones) → setar no `/opt/forzion/.env`. `docker-compose.yml` (local dev) mantém só PhoneNumberId/AccessToken/ApiVersion comentadas. Local sem Docker: User Secrets. Chaves documentadas em `.env.example`. +- Deploy: env vars no compose. `docker-compose.homolog.yml`/`docker-compose.server.yml` mapeiam `WhatsApp__*` ← `${WHATSAPP_*}` (Habilitado [default `false`], PhoneNumberId, AccessToken, ApiVersion, AppSecret, WebhookVerifyToken, MarcarComoTeste, RedirecionarDestinatariosPara, AllowlistTelefones) → setar no `/opt/forzion/.env`. `docker-compose.yml` (local dev) mantém só PhoneNumberId/AccessToken/ApiVersion comentadas. Local sem Docker: User Secrets. Chaves documentadas em `.env.example`. ## COMPONENTES - `MetaWhatsAppCloudNotifier` / `NullWhatsAppNotifier` / `EnvironmentWhatsAppDecorator`. @@ -130,6 +131,16 @@ Fonte das vars: `WhatsAppTemplates.cs` (ordem dos `{{n}}` = ordem do array, IMUT - Unit (xUnit, sem Docker) em `forzion.tech.Tests`: notifier/webhook/handler tests (`MetaWhatsAppCloudNotifierTests`, `ProcessarWebhookWhatsAppHandlerTests`, `WhatsAppDeliveryLogTests`, handlers dos 5 adaptados + 10 novos). Cobrem: Habilitado false no-op, entidade não encontrada, telefone null, happy path (assert template Name + body params). - E2E/Infra (Testcontainers) exigem Docker → CI. +## DESATIVAÇÃO TEMPORÁRIA (estado atual — feature `desativar-whatsapp-temporario`) +Verificação de negócio da Meta Business Manager bloqueada (razão social ausente no site, resolvendo fora deste código). Até aprovar, a integração fica desligada por um kill-switch explícito, não mais implícito pela ausência de credencial. + +- **Config**: `WhatsApp:Habilitado=false` (default) em todos os ambientes. Gate DI força `NullWhatsAppNotifier` mesmo com credencial válida configurada (ver §STACK & GATE). +- **Webhook**: `POST /webhooks/whatsapp` checa a flag ANTES de ler/validar payload (HMAC); `false` → `200 Ok()` no-op sem instanciar o command/handler (evita oráculo de assinatura). `GET` (handshake) inalterado, independe da flag — mantém a inscrição do webhook ativa na Meta. +- **Health check**: `WhatsAppHealthCheck` checa a flag antes de checar credencial; `false` → `Healthy("WhatsApp desativado temporariamente.")` sem bater na Graph API — o desligamento proposital não derruba `/health/ready`. +- **Plano ProPlus** (único tier ativo que confere WhatsApp — Elite já inativo): migration `InativarPlanoProPlus` (`is_ativo=false` em `planos_plataforma` WHERE `tier='ProPlus'`) — propaga automaticamente pra landing, contratar, trocar e o guard de backend (`plano_plataforma.inativo`), que já leem `is_ativo`/`isAtivo`. `DataSeeder` local reflete o mesmo estado. +- **Frontend**: chip "WhatsApp" em Meu Plano sobreposto por `WHATSAPP_TEMPORARIAMENTE_INDISPONIVEL` (`frontend/src/lib/config/feature-flags.ts`, hoje `true`) — mostra label/tooltip de indisponibilidade temporária independente do `tierEfetivo` (cobre tier legado ProPlus/Elite que já teria direito ao canal). +- **Reativação** (sem UI admin, flip manual): (1) `WhatsApp:Habilitado=true` nos envs desejados; (2) reverter a migration (`is_ativo=true` em `planos_plataforma` WHERE `tier='ProPlus'`) OU nova migration equivalente; (3) `WHATSAPP_TEMPORARIAMENTE_INDISPONIVEL=false` no frontend. Templates aprovados na Meta continuam válidos — não precisam ser re-submetidos. + ## GAPS / ROADMAP — status Paridade email→WhatsApp **FECHADA** (todos os eventos de e-mail relevantes têm WhatsApp; verify/reset e health-report admin permanecem N/A por serem channel-bound/internos). Itens estruturais FECHADOS: type:template, `EnvironmentWhatsAppDecorator`, padronização event-handlers, `Habilitado`, catálogo central, E.164, webhook de status.