diff --git a/backend/src/main/java/com/edtech/controller/AuthController.java b/backend/src/main/java/com/edtech/controller/AuthController.java index d93bee887..226b3bfd6 100644 --- a/backend/src/main/java/com/edtech/controller/AuthController.java +++ b/backend/src/main/java/com/edtech/controller/AuthController.java @@ -5,6 +5,7 @@ import com.edtech.dto.RecoveryRequestDto; import com.edtech.dto.RegisterRequestDto; import com.edtech.dto.ResetPasswordDto; +import com.edtech.dto.UpdateProfileRequestDto; import com.edtech.dto.UserResponseDto; import com.edtech.dto.VerifyCodeDto; import com.edtech.exception.RateLimitExceededException; @@ -22,6 +23,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -259,4 +261,14 @@ public ResponseEntity me(Authentication authentication) { User user = (User) authentication.getPrincipal(); return ResponseEntity.ok(UserResponseDto.from(user)); } + + /** Atualiza os dados de apresentação da conta autenticada. */ + @PatchMapping("/me") + public ResponseEntity updateProfile( + @Valid @RequestBody UpdateProfileRequestDto request, Authentication authentication) { + User user = (User) authentication.getPrincipal(); + user.setName(request.name().trim()); + user.setAvatarUrl(request.avatarUrl()); + return ResponseEntity.ok(UserResponseDto.from(userService.saveUserWithoutHash(user))); + } } diff --git a/backend/src/main/java/com/edtech/controller/LaboratoryController.java b/backend/src/main/java/com/edtech/controller/LaboratoryController.java index 4822d2ce5..1dfdd0aa1 100644 --- a/backend/src/main/java/com/edtech/controller/LaboratoryController.java +++ b/backend/src/main/java/com/edtech/controller/LaboratoryController.java @@ -7,7 +7,7 @@ import java.util.Map; import java.util.Optional; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -31,8 +31,8 @@ public LaboratoryController( /** Javadoc. */ @GetMapping("/token") - public ResponseEntity getLaboratoryToken(@AuthenticationPrincipal UserDetails userDetails) { - Optional userOpt = userRepository.findByEmailIgnoreCase(userDetails.getUsername()); + public ResponseEntity getLaboratoryToken(Authentication authentication) { + Optional userOpt = getCurrentUser(authentication); if (userOpt.isEmpty() || userOpt.get().getRole() != UserRole.ADVISOR) { return ResponseEntity.status(403) .body(Map.of("error", "Apenas orientadores podem gerar tokens do laboratorio")); @@ -52,13 +52,13 @@ public ResponseEntity getLaboratoryToken(@AuthenticationPrincipal UserDetails /** Javadoc. */ @PostMapping("/join") public ResponseEntity joinLaboratory( - @AuthenticationPrincipal UserDetails userDetails, @RequestBody Map request) { + Authentication authentication, @RequestBody Map request) { String token = request.get("token"); if (token == null || token.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "O campo token e obrigatorio")); } - Optional currentUserOpt = userRepository.findByEmailIgnoreCase(userDetails.getUsername()); + Optional currentUserOpt = getCurrentUser(authentication); if (currentUserOpt.isEmpty()) { return ResponseEntity.status(401).build(); } @@ -82,4 +82,18 @@ public ResponseEntity joinLaboratory( "advisor_name", advisor.getName(), "institution_id", advisor.getInstitutionId())); } + + private Optional getCurrentUser(Authentication authentication) { + if (authentication == null) { + return Optional.empty(); + } + Object principal = authentication.getPrincipal(); + if (principal instanceof User user) { + return Optional.of(user); + } + if (principal instanceof UserDetails userDetails) { + return userRepository.findByEmailIgnoreCase(userDetails.getUsername()); + } + return Optional.empty(); + } } diff --git a/backend/src/main/java/com/edtech/dto/ProjectResponseDto.java b/backend/src/main/java/com/edtech/dto/ProjectResponseDto.java index 255a61e14..622f82bf0 100644 --- a/backend/src/main/java/com/edtech/dto/ProjectResponseDto.java +++ b/backend/src/main/java/com/edtech/dto/ProjectResponseDto.java @@ -10,6 +10,7 @@ public class ProjectResponseDto { private String description; private UUID advisorId; private ZonedDateTime createdAt; + private boolean member; // Getters and Setters /** Documentação para o método getId. */ @@ -61,4 +62,12 @@ public ZonedDateTime getCreatedAt() { public void setCreatedAt(ZonedDateTime createdAt) { this.createdAt = createdAt; } + + public boolean isMember() { + return member; + } + + public void setMember(boolean member) { + this.member = member; + } } diff --git a/backend/src/main/java/com/edtech/dto/UpdateProfileRequestDto.java b/backend/src/main/java/com/edtech/dto/UpdateProfileRequestDto.java new file mode 100644 index 000000000..0bfd831e2 --- /dev/null +++ b/backend/src/main/java/com/edtech/dto/UpdateProfileRequestDto.java @@ -0,0 +1,8 @@ +package com.edtech.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** Dados editáveis do perfil autenticado. */ +public record UpdateProfileRequestDto( + @NotBlank @Size(max = 120) String name, @Size(max = 2_000_000) String avatarUrl) {} diff --git a/backend/src/main/java/com/edtech/dto/UserResponseDto.java b/backend/src/main/java/com/edtech/dto/UserResponseDto.java index f241bbe45..27a69c893 100644 --- a/backend/src/main/java/com/edtech/dto/UserResponseDto.java +++ b/backend/src/main/java/com/edtech/dto/UserResponseDto.java @@ -13,7 +13,8 @@ public record UserResponseDto( boolean active, Instant createdAt, boolean mfaEnabled, - UUID institutionId) { + UUID institutionId, + String avatarUrl) { /** Documentação para o método from. */ public static UserResponseDto from(User user) { @@ -25,6 +26,7 @@ public static UserResponseDto from(User user) { user.isActive(), user.getCreatedAt(), user.isMfaEnabled(), - user.getInstitutionId()); + user.getInstitutionId(), + user.getAvatarUrl()); } } diff --git a/backend/src/main/java/com/edtech/model/User.java b/backend/src/main/java/com/edtech/model/User.java index 3c0466b1b..4c7947fbd 100644 --- a/backend/src/main/java/com/edtech/model/User.java +++ b/backend/src/main/java/com/edtech/model/User.java @@ -66,6 +66,9 @@ public class User { @Column(name = "mfa_secret", length = 32) private String mfaSecret; + @Column(name = "avatar_url", columnDefinition = "TEXT") + private String avatarUrl; + protected User() {} /** Documentação para o método User. */ @@ -109,6 +112,10 @@ public String getName() { return name; } + public void setName(String name) { + this.name = name; + } + /** Documentação para o método getEmail. */ public String getEmail() { return email; @@ -177,4 +184,12 @@ public String getMfaSecret() { public void setMfaSecret(String mfaSecret) { this.mfaSecret = mfaSecret; } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } } diff --git a/backend/src/main/java/com/edtech/repository/ProjectRepository.java b/backend/src/main/java/com/edtech/repository/ProjectRepository.java index b2ff99baf..6776eaeb4 100644 --- a/backend/src/main/java/com/edtech/repository/ProjectRepository.java +++ b/backend/src/main/java/com/edtech/repository/ProjectRepository.java @@ -14,4 +14,10 @@ public interface ProjectRepository extends JpaRepository { + "WHERE pm.user.id = :userId") org.springframework.data.domain.Page findProjectsByUserId( @Param("userId") UUID userId, org.springframework.data.domain.Pageable pageable); + + @Query( + "SELECT p FROM Project p WHERE p.advisor.institutionId = :institutionId") + org.springframework.data.domain.Page findProjectsByInstitutionId( + @Param("institutionId") UUID institutionId, + org.springframework.data.domain.Pageable pageable); } diff --git a/backend/src/main/java/com/edtech/service/EmailService.java b/backend/src/main/java/com/edtech/service/EmailService.java index 51900ad6e..0d2e78f68 100644 --- a/backend/src/main/java/com/edtech/service/EmailService.java +++ b/backend/src/main/java/com/edtech/service/EmailService.java @@ -1,5 +1,11 @@ package com.edtech.service; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -10,25 +16,27 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -/** Documentação para EmailService. */ +/** Sends account emails through the Resend HTTPS API in production and SMTP locally. */ @Service public class EmailService { private static final Logger logger = LoggerFactory.getLogger(EmailService.class); + private static final URI RESEND_EMAILS_URI = URI.create("https://api.resend.com/emails"); @Autowired private JavaMailSender mailSender; @Value("${SMTP_FROM:noreply@edtechacademic.com.br}") private String fromAddress; - /** Documentação. */ + @Value("${SMTP_PASSWORD:}") + private String resendApiKey; + + /** Sends the password recovery code. */ @Async public void sendRecoveryEmail(String toEmail, String otpCode) { - SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom(resolveFromAddress()); - message.setTo(toEmail); - message.setSubject("Código de Recuperação de Senha - EdTech"); - message.setText( + sendEmail( + toEmail, + "Código de Recuperação de Senha - EdTech", "Olá,\n\n" + "Você solicitou a recuperação da sua senha na EdTech.\n" + "Seu código de segurança (OTP) de 6 dígitos é: " @@ -37,25 +45,14 @@ public void sendRecoveryEmail(String toEmail, String otpCode) { + "Este código é válido por 15 minutos.\n" + "Se você não solicitou isso, ignore este e-mail.\n\n" + "Equipe EdTech"); - - try { - mailSender.send(message); - logger.info("Recovery email dispatched to {}.", toEmail); - } catch (MailException ex) { - logger.warn( - "Não foi possível enviar e-mail real via SMTP " - + "(verifique credenciais). Usando apenas simulação no console."); - } } - /** Javadoc. */ + /** Sends the registration verification code. */ @Async public void sendVerificationEmail(String toEmail, String otpCode) { - SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom(resolveFromAddress()); - message.setTo(toEmail); - message.setSubject("Código de Verificação de Conta - EdTech"); - message.setText( + sendEmail( + toEmail, + "Código de Verificação de Conta - EdTech", "Olá,\n\n" + "Bem-vindo à EdTech!\n" + "Seu código de verificação (OTP) de 6 dígitos é: " @@ -64,17 +61,72 @@ public void sendVerificationEmail(String toEmail, String otpCode) { + "Este código é válido por 15 minutos.\n" + "Se você não solicitou isso, ignore este e-mail.\n\n" + "Equipe EdTech"); + } + + private void sendEmail(String toEmail, String subject, String text) { + String apiKey = resendApiKey == null ? "" : resendApiKey.trim(); + if (apiKey.startsWith("re_")) { + sendWithResendApi(apiKey, toEmail, subject, text); + return; + } + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(resolveFromAddress()); + message.setTo(toEmail); + message.setSubject(subject); + message.setText(text); try { mailSender.send(message); - logger.info("Verification email dispatched to {}.", toEmail); + logger.info("Email dispatched through SMTP to {}.", toEmail); } catch (MailException ex) { - logger.warn( - "Não foi possível enviar e-mail real via SMTP " - + "(verifique credenciais). Usando apenas simulação no console."); + logger.warn("SMTP email delivery failed for {}.", toEmail, ex); } } + private void sendWithResendApi(String apiKey, String toEmail, String subject, String text) { + String payload = + "{\"from\":\"" + + escapeJson(resolveFromAddress()) + + "\",\"to\":[\"" + + escapeJson(toEmail) + + "\"],\"subject\":\"" + + escapeJson(subject) + + "\",\"text\":\"" + + escapeJson(text) + + "\"}"; + HttpRequest request = + HttpRequest.newBuilder(RESEND_EMAILS_URI) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8)) + .build(); + try { + HttpResponse response = + HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 200 && response.statusCode() < 300) { + logger.info("Email dispatched through Resend API to {}.", toEmail); + } else { + logger.warn( + "Resend API rejected email for {} with HTTP status {}.", + toEmail, + response.statusCode()); + } + } catch (IOException ex) { + logger.warn("Resend API delivery failed for {}.", toEmail, ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + logger.warn("Resend API delivery interrupted for {}.", toEmail, ex); + } + } + + private String escapeJson(String value) { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r"); + } + private String resolveFromAddress() { return fromAddress == null || fromAddress.isBlank() ? "noreply@edtechacademic.com.br" diff --git a/backend/src/main/java/com/edtech/service/ProjectService.java b/backend/src/main/java/com/edtech/service/ProjectService.java index 9a5cfe7c0..cdcdbe7d9 100644 --- a/backend/src/main/java/com/edtech/service/ProjectService.java +++ b/backend/src/main/java/com/edtech/service/ProjectService.java @@ -69,7 +69,20 @@ public ProjectResponseDto createProject(ProjectRequestDto request, UUID advisorI key = "#userId + '-' + #pageable.pageNumber + '-' + #pageable.pageSize") public org.springframework.data.domain.Page listProjectsByUser( UUID userId, org.springframework.data.domain.Pageable pageable) { - return projectRepository.findProjectsByUserId(userId, pageable).map(this::mapToDto); + User user = userRepository.findById(userId).orElse(null); + if (user == null) { + return projectRepository + .findProjectsByUserId(userId, pageable) + .map(project -> mapToDto(project, userId)); + } + if (user.getRole() == com.edtech.model.UserRole.ADVISOR) { + return projectRepository + .findProjectsByUserId(userId, pageable) + .map(project -> mapToDto(project, userId)); + } + return projectRepository + .findProjectsByInstitutionId(user.getInstitutionId(), pageable) + .map(project -> mapToDto(project, userId)); } /** Documentação. */ @@ -124,12 +137,21 @@ public void addMember(UUID projectId, ProjectMemberRequestDto dto, User authenti } private ProjectResponseDto mapToDto(Project project) { + return mapToDto(project, null); + } + + private ProjectResponseDto mapToDto(Project project, UUID currentUserId) { ProjectResponseDto dto = new ProjectResponseDto(); dto.setId(project.getId()); dto.setTitle(project.getTitle()); dto.setDescription(project.getDescription()); dto.setAdvisorId(project.getAdvisor().getId()); dto.setCreatedAt(project.getCreatedAt()); + dto.setMember( + currentUserId != null + && projectMemberRepository + .findByProjectIdAndUserId(project.getId(), currentUserId) + .isPresent()); return dto; } } diff --git a/backend/src/main/resources/db/migration/V15__add_avatar_url_to_users.sql b/backend/src/main/resources/db/migration/V15__add_avatar_url_to_users.sql new file mode 100644 index 000000000..cae221425 --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__add_avatar_url_to_users.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT; diff --git a/backend/src/test/java/com/edtech/controller/LaboratoryControllerTest.java b/backend/src/test/java/com/edtech/controller/LaboratoryControllerTest.java index 520f09c02..eed8589a4 100644 --- a/backend/src/test/java/com/edtech/controller/LaboratoryControllerTest.java +++ b/backend/src/test/java/com/edtech/controller/LaboratoryControllerTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; class LaboratoryControllerTest { @@ -43,7 +44,7 @@ void getLaboratoryToken_Success() { when(tokenService.generateToken(advisorId, UserRole.RESEARCHER)).thenReturn("123456"); when(tokenService.generateToken(advisorId, UserRole.AUDITOR)).thenReturn("654321"); - ResponseEntity response = controller.getLaboratoryToken(userDetails); + ResponseEntity response = controller.getLaboratoryToken(authenticationFor(userDetails)); assertEquals(200, response.getStatusCode().value()); Map body = (Map) response.getBody(); assertEquals("123456", body.get("researcher_token")); @@ -60,7 +61,7 @@ void getLaboratoryToken_NotAdvisor() { when(userRepository.findByEmailIgnoreCase("student@unb.br")).thenReturn(Optional.of(student)); - ResponseEntity response = controller.getLaboratoryToken(userDetails); + ResponseEntity response = controller.getLaboratoryToken(authenticationFor(userDetails)); assertEquals(403, response.getStatusCode().value()); } @@ -78,7 +79,7 @@ void joinLaboratory_Success() { when(tokenService.findAdvisorByToken(eq("123456"), any())).thenReturn(Optional.of(advisor)); when(userRepository.findByEmailIgnoreCase("student@unb.br")).thenReturn(Optional.of(student)); - ResponseEntity response = controller.joinLaboratory(userDetails, Map.of("token", "123456")); + ResponseEntity response = controller.joinLaboratory(authenticationFor(userDetails), Map.of("token", "123456")); assertEquals(200, response.getStatusCode().value()); verify(userRepository).save(student); assertEquals(advisor.getInstitutionId(), student.getInstitutionId()); @@ -87,7 +88,7 @@ void joinLaboratory_Success() { @Test void joinLaboratory_NoToken() { UserDetails userDetails = mock(UserDetails.class); - ResponseEntity response = controller.joinLaboratory(userDetails, Map.of()); + ResponseEntity response = controller.joinLaboratory(authenticationFor(userDetails), Map.of()); assertEquals(400, response.getStatusCode().value()); } @@ -100,7 +101,7 @@ void joinLaboratory_InvalidToken() { when(userRepository.findByEmailIgnoreCase("student@unb.br")).thenReturn(Optional.of(student)); when(tokenService.findAdvisorByToken("123456", UserRole.RESEARCHER)) .thenReturn(Optional.empty()); - ResponseEntity response = controller.joinLaboratory(userDetails, Map.of("token", "123456")); + ResponseEntity response = controller.joinLaboratory(authenticationFor(userDetails), Map.of("token", "123456")); assertEquals(400, response.getStatusCode().value()); } @@ -112,7 +113,13 @@ void joinLaboratory_UserNotFound() { new User("Dr. Advisor", "advisor@unb.br", "hash", UserRole.ADVISOR, UUID.randomUUID()); when(userRepository.findByEmailIgnoreCase("student@unb.br")).thenReturn(Optional.empty()); - ResponseEntity response = controller.joinLaboratory(userDetails, Map.of("token", "123456")); + ResponseEntity response = controller.joinLaboratory(authenticationFor(userDetails), Map.of("token", "123456")); assertEquals(401, response.getStatusCode().value()); } + + private Authentication authenticationFor(UserDetails userDetails) { + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(userDetails); + return authentication; + } } diff --git a/frontend/firebase.json b/frontend/firebase.json index 62ee59380..8a8e6e7ed 100644 --- a/frontend/firebase.json +++ b/frontend/firebase.json @@ -12,7 +12,7 @@ "headers": [ { "key": "Content-Security-Policy", - "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://edtech-backend-shv6qbpf4q-rj.a.run.app" + "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' https://edtech-backend-shv6qbpf4q-rj.a.run.app" }, { "key": "Strict-Transport-Security", diff --git a/frontend/public/icons/pwa-icon.svg b/frontend/public/icons/pwa-icon.svg new file mode 100644 index 000000000..7252cea85 --- /dev/null +++ b/frontend/public/icons/pwa-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest index 9255ebae1..a34a2063a 100644 --- a/frontend/public/manifest.webmanifest +++ b/frontend/public/manifest.webmanifest @@ -8,9 +8,9 @@ "theme_color": "#3d1482", "icons": [ { - "src": "/icons/icon-512.png", - "sizes": "512x512", - "type": "image/png", + "src": "/icons/pwa-icon.svg", + "sizes": "any", + "type": "image/svg+xml", "purpose": "any maskable" } ] diff --git a/frontend/src/assets/dashboard.css b/frontend/src/assets/dashboard.css index 651f6ddef..b36e4c135 100644 --- a/frontend/src/assets/dashboard.css +++ b/frontend/src/assets/dashboard.css @@ -517,6 +517,24 @@ body:has(.dashboard-layout) #root { box-shadow: 0 4px 15px rgba(126, 76, 203, 0.3); } +.governance-alert .alert-content { min-width: 0; } +.governance-alert .alert-text-container { min-width: 0; } +.governance-alert .alert-desc { overflow-wrap: anywhere; } +.governance-primary-action { + flex: 0 0 auto; + min-width: max-content; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + background: var(--ed-purple-dark); + color: #fff; + border: 1px solid rgba(255,255,255,.22); + box-shadow: 0 6px 18px rgba(18, 10, 43, .22); +} +.governance-primary-action:hover { background: #250d5c; transform: translateY(-1px); } +.card-header-flex .card-action-link { background: transparent; border: 0; padding: 4px; } + .alert-content { display: flex; align-items: center; @@ -1018,6 +1036,9 @@ body:has(.dashboard-layout) #root { * ------------------------------------------------------------- */ @media (max-width: 767.98px) { + .governance-alert { padding: 16px; } + .governance-alert .alert-content { align-items: flex-start; } + .governance-primary-action { width: 100%; min-width: 0; } .sidebar-container { position: fixed; top: 0; diff --git a/frontend/src/assets/settings.css b/frontend/src/assets/settings.css index faef3dddd..b9ce9c285 100644 --- a/frontend/src/assets/settings.css +++ b/frontend/src/assets/settings.css @@ -45,6 +45,42 @@ flex-shrink: 0; } +.settings-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; +} + +.profile-editor { + position: fixed; + inset: 0; + z-index: 1100; + display: grid; + place-items: center; + padding: 16px; + background: rgba(18, 10, 43, .55); +} + +.profile-editor-card { + width: min(100%, 480px); + padding: 24px; + border-radius: 16px; + background: var(--bg); + border: 1px solid var(--border); + box-shadow: 0 24px 64px rgba(0, 0, 0, .28); + display: grid; + gap: 10px; +} + +.profile-editor-header, .profile-editor-actions, .profile-avatar-picker { display: flex; align-items: center; gap: 12px; } +.profile-editor-header { justify-content: space-between; } +.profile-editor-header h3 { margin: 0; color: var(--ed-text-dark); } +.profile-editor-actions { justify-content: flex-end; margin-top: 8px; } +.profile-close { background: transparent; border: 0; color: var(--ed-text-muted); font-size: 28px; line-height: 1; cursor: pointer; } +.profile-avatar-picker input { max-width: 100%; color: var(--ed-text-muted); } +.profile-message, .settings-error { margin: 4px 0 0; color: var(--ed-status-danger); font-size: 13px; } + .settings-profile-info { display: flex; flex-direction: column; @@ -289,6 +325,10 @@ input:checked + .ed-slider:before { margin-top: 4px; } + .profile-editor-card { padding: 20px; } + .profile-editor-actions { flex-direction: column-reverse; align-items: stretch; } + .profile-editor-actions button { width: 100%; } + .notifications-dropdown { position: fixed !important; top: 70px !important; @@ -324,9 +364,11 @@ input:checked + .ed-slider:before { .settings-code-form { display: flex; gap: .5rem; width: 100%; max-width: 400px; margin-left: 56px; } .settings-access-code { background: var(--bg); padding: 16px; border-radius: 8px; border: 1px solid var(--border); } +.settings-code-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid #f0b5b5; border-radius: 8px; color: #a61b1b; font-size: 14px; } @media (max-width: 768px) { .settings-code-form { margin-left: 0; max-width: none; flex-wrap: wrap; } + .settings-code-status { align-items: flex-start; flex-direction: column; } .settings-code-form .ed-input { flex: 1 1 180px; min-height: 44px; } .settings-code-form .btn-primary { min-height: 44px; } } diff --git a/frontend/src/assets/trail.css b/frontend/src/assets/trail.css index a33c025f3..44bff726f 100644 --- a/frontend/src/assets/trail.css +++ b/frontend/src/assets/trail.css @@ -411,10 +411,23 @@ color: var(--ed-status-danger); } +.version-compare-backdrop { position: fixed; inset: 0; z-index: 1100; padding: 16px; display: grid; place-items: center; background: rgba(18,10,43,.55); } +.version-compare-dialog { width: min(100%, 680px); border: 1px solid var(--border); border-radius: 16px; padding: 24px; background: var(--bg); box-shadow: 0 24px 64px rgba(0,0,0,.28); } +.version-compare-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; } +.version-compare-header h3, .version-compare-header p { margin: 0; color: var(--ed-text-dark); } +.version-compare-header p { margin-top: 4px; color: var(--ed-text-muted); font-size: 13px; } +.version-compare-header button { border: 0; background: transparent; color: var(--ed-text-muted); font-size: 28px; cursor: pointer; } +.version-compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.version-compare-grid section { padding: 16px; border-radius: 10px; border: 1px solid var(--border); background: rgba(126,76,203,.05); color: var(--ed-text-dark); } +.version-compare-grid p, .version-compare-summary { margin: 8px 0 0; color: var(--ed-text-muted); font-size: 14px; } +.version-compare-summary i { color: var(--ed-status-success); } + /* ------------------------------------------------------------- * RESPONSIVE / MOBILE OPTIMIZATIONS (360px a 768px) * ------------------------------------------------------------- */ @media (max-width: 767.98px) { + .version-compare-dialog { padding: 18px; } + .version-compare-grid { grid-template-columns: 1fr; } .trail-content-grid { grid-template-columns: 1fr; gap: 16px; @@ -511,4 +524,4 @@ white-space: normal !important; word-break: break-word !important; } -} \ No newline at end of file +} diff --git a/frontend/src/pages/__tests__/dashboard.test.tsx b/frontend/src/pages/__tests__/dashboard.test.tsx index b6bc00e9c..be9131f1c 100644 --- a/frontend/src/pages/__tests__/dashboard.test.tsx +++ b/frontend/src/pages/__tests__/dashboard.test.tsx @@ -159,9 +159,9 @@ describe('Dashboard Page', () => { // Check alert expect(screen.getByText('PRÓXIMO PASSO')).toBeInTheDocument(); - const detailsBtn = screen.getByText('Começar'); + const detailsBtn = screen.getByText('Abrir fila de revisão'); fireEvent.click(detailsBtn); - expect(mockNavigate).toHaveBeenCalledWith('/projects'); + expect(mockNavigate).toHaveBeenCalledWith('/submissions'); // Check recent docs expect(screen.getByText('Revisões Pendentes')).toBeInTheDocument(); diff --git a/frontend/src/pages/__tests__/projects.test.tsx b/frontend/src/pages/__tests__/projects.test.tsx index e2b688b28..5a1f979c9 100644 --- a/frontend/src/pages/__tests__/projects.test.tsx +++ b/frontend/src/pages/__tests__/projects.test.tsx @@ -9,6 +9,9 @@ vi.mock('../../services/api', () => ({ joinProject: vi.fn(), })); +vi.mock('../../context/authContext', () => ({ useAuth: vi.fn(() => ({ user: { role: 'RESEARCHER' } })) })); +vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); + vi.mock('../../components/layout/DashboardLayout', () => ({ default: ({ children, title }: any) => (
diff --git a/frontend/src/pages/__tests__/settings.test.tsx b/frontend/src/pages/__tests__/settings.test.tsx index 06f14fe45..ac4fa5508 100644 --- a/frontend/src/pages/__tests__/settings.test.tsx +++ b/frontend/src/pages/__tests__/settings.test.tsx @@ -12,6 +12,9 @@ vi.mock('../../context/authContext', () => ({ vi.mock('../../services/api', () => ({ setup2Fa: vi.fn(), enable2Fa: vi.fn(), + joinLaboratory: vi.fn(), + updateProfile: vi.fn(), + getLaboratoryTokens: vi.fn(), })); vi.mock('../../components/layout/DashboardLayout', () => ({ @@ -144,6 +147,22 @@ describe('Settings Page', () => { expect(screen.getByText('usuario@edtech.com')).toBeInTheDocument(); }); + it('shows association codes to an advisor', async () => { + (authContext.useAuth as any).mockReturnValue({ + user: { name: 'Orientador Demo', email: 'orientador@unb.br', role: 'ADVISOR', mfaEnabled: false }, + }); + (apiServices.getLaboratoryTokens as any).mockResolvedValue({ + sucesso: true, + dados: { researcher_token: '123456', auditor_token: '654321' }, + }); + + render(); + + expect(screen.getByText(/C.digos de Acesso do Laborat.rio/i)).toBeInTheDocument(); + expect(await screen.findByText('123456')).toBeInTheDocument(); + expect(screen.getByText('654321')).toBeInTheDocument(); + }); + it('handles 2FA enable error fallback message', async () => { (apiServices.setup2Fa as any).mockResolvedValue({ sucesso: true, dados: { qrCodeUri: 'mock-uri' } }); (apiServices.enable2Fa as any).mockResolvedValue({ sucesso: false }); // No message diff --git a/frontend/src/pages/__tests__/trail.test.tsx b/frontend/src/pages/__tests__/trail.test.tsx index c6731d803..231b5a1e8 100644 --- a/frontend/src/pages/__tests__/trail.test.tsx +++ b/frontend/src/pages/__tests__/trail.test.tsx @@ -57,7 +57,7 @@ describe('ResearchTrail Page', () => { fireEvent.click(screen.getByText('Resultados_Parciais_Q2.pdf')); - expect(screen.queryByText('Timeline de decisões')).not.toBeInTheDocument(); + expect(screen.getByText('Timeline de decisões')).toBeInTheDocument(); const doc1Elements = screen.getAllByText('Metodologia_Qualitativa_v3.pdf'); fireEvent.click(doc1Elements[0]); // Click the item in list diff --git a/frontend/src/pages/compliance.tsx b/frontend/src/pages/compliance.tsx index a3a172117..819762e80 100644 --- a/frontend/src/pages/compliance.tsx +++ b/frontend/src/pages/compliance.tsx @@ -6,6 +6,39 @@ import '../assets/auditor.css'; const ComplianceCenter: React.FC = () => { const [stats, setStats] = useState(null); + const exportReport = () => { + if (!stats) return; + const lines = [ + 'EDTECH ACADEMIC - RELATORIO DE CONFORMIDADE', + `Gerado em: ${new Date().toLocaleString('pt-BR')}`, + `Compliance score: ${stats.score}%`, + `Politicas conformes: ${stats.compliantPolicies}/${stats.totalPolicies}`, + `Itens pendentes: ${stats.pendingItems}`, + `Eventos auditados: ${stats.totalEvents}`, + ...stats.policies.map((policy: any) => `${policy.name}: ${policy.percentage}% - ${policy.status}`), + ]; + const escapePdf = (value: string) => value.replace(/[\\()]/g, '\\$&').replace(/[^\x20-\x7E]/g, ''); + const stream = `BT /F1 13 Tf 48 790 Td ${lines.map((line: string, index: number) => `${index ? '0 -24 Td ' : ''}(${escapePdf(line)}) Tj`).join(' ')} ET`; + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ]; + let pdf = '%PDF-1.4\n'; + const offsets = [0]; + objects.forEach((object, index) => { offsets.push(pdf.length); pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; }); + const xrefOffset = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; + const url = URL.createObjectURL(new Blob([pdf], { type: 'application/pdf' })); + const link = document.createElement('a'); + link.href = url; + link.download = 'relatorio_conformidade_edtech.pdf'; + link.click(); + URL.revokeObjectURL(url); + }; + useEffect(() => { const fetchStats = async () => { const data = await getComplianceStats(); @@ -23,12 +56,9 @@ const ComplianceCenter: React.FC = () => { breadcrumbs={['EdTech', 'Centro de Conformidade']} customTopbarElement={
- - diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 24c459e77..1db6d1153 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -310,7 +310,7 @@ const Dashboard: React.FC = () => {
- @@ -326,13 +326,13 @@ const Dashboard: React.FC = () => { {user?.role === 'RESEARCHER' ? 'Envie seu primeiro documento para iniciar a trilha de pesquisa e as métricas do painel.' : user?.role === 'ADVISOR' - ? 'Associe-se a um projeto para acompanhar submissões e orientar pesquisadores.' + ? 'Acompanhe as submissões da sua equipe e conduza as próximas revisões.' : 'Consulte os logs de auditoria para acompanhar eventos e indicadores de conformidade.'} - )} @@ -345,7 +345,7 @@ const Dashboard: React.FC = () => {

Minhas Submissões Recentes

- Ver histórico +
{recentDocs.length > 0 ? recentDocs.map((doc: any, i) => ( @@ -408,7 +408,7 @@ const Dashboard: React.FC = () => {

Revisões Pendentes

- Ver todas +
{recentDocs.length > 0 ? recentDocs.map((doc: any, i) => ( diff --git a/frontend/src/pages/projects.tsx b/frontend/src/pages/projects.tsx index 860aff5dd..725584701 100644 --- a/frontend/src/pages/projects.tsx +++ b/frontend/src/pages/projects.tsx @@ -1,9 +1,13 @@ import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/layout/DashboardLayout'; import { getProjects, joinProject } from '../services/api'; import { Project } from '../types'; +import { useAuth } from '../context/authContext'; export default function Projects() { + const { user } = useAuth(); + const navigate = useNavigate(); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(0); @@ -39,19 +43,22 @@ export default function Projects() { showToast('Associado ao projeto com sucesso!'); loadProjects(); } else { - showToast('Erro: ' + resp.mensagem); + const alreadyJoined = /already a member|j[aá] associado/i.test(resp.mensagem || ''); + showToast(alreadyJoined ? 'Você já está associado a este projeto.' : 'Erro: ' + resp.mensagem); } }; + const canJoinProjects = user?.role === 'RESEARCHER' || user?.role === 'AUDITOR'; + return (

- Projetos Disponíveis + {canJoinProjects ? 'Projetos disponíveis' : 'Projetos orientados'}

{loading ? (
Carregando projetos...
@@ -76,7 +83,7 @@ export default function Projects() {
-

{project.name}

+

{project.title || project.name}

Criado em {project.createdAt ? new Date(project.createdAt).toLocaleDateString() : 'N/A'}
@@ -86,7 +93,8 @@ export default function Projects() {

diff --git a/frontend/src/pages/settings.tsx b/frontend/src/pages/settings.tsx index 36c8447ed..5dfd31d82 100644 --- a/frontend/src/pages/settings.tsx +++ b/frontend/src/pages/settings.tsx @@ -2,10 +2,10 @@ import React, { useState } from 'react'; import DashboardLayout from '../components/layout/DashboardLayout'; import '../assets/settings.css'; import { useAuth } from '../context/authContext'; -import { setup2Fa, enable2Fa, joinLaboratory } from '../services/api'; +import { setup2Fa, enable2Fa, getLaboratoryTokens, joinLaboratory, updateProfile } from '../services/api'; const Settings: React.FC = () => { - const { user } = useAuth(); + const { user, checkAuth } = useAuth(); const [reviewEmails, setReviewEmails] = useState(true); const [strictLgpd, setStrictLgpd] = useState(true); @@ -18,18 +18,31 @@ const Settings: React.FC = () => { const [isJoining, setIsJoining] = useState(false); const [joinMessage, setJoinMessage] = useState(''); const [labTokens, setLabTokens] = useState<{researcher: string, auditor: string} | null>(null); + const [isLoadingLabTokens, setIsLoadingLabTokens] = useState(false); + const [labTokensError, setLabTokensError] = useState(''); + const [isEditingProfile, setIsEditingProfile] = useState(false); + const [profileName, setProfileName] = useState(user?.name || ''); + const [avatarPreview, setAvatarPreview] = useState(user?.avatarUrl || null); + const [profileMessage, setProfileMessage] = useState(''); + + const loadLaboratoryTokens = async () => { + setIsLoadingLabTokens(true); + setLabTokensError(''); + const response = await getLaboratoryTokens(); + if (response.sucesso && response.dados) { + setLabTokens({ researcher: response.dados.researcher_token, auditor: response.dados.auditor_token }); + } else { + setLabTokens(null); + setLabTokensError(response.mensagem || 'Não foi possível carregar os códigos de associação.'); + } + setIsLoadingLabTokens(false); + }; React.useEffect(() => { if (user?.role === 'ADVISOR') { - import('../services/api').then(({ getLaboratoryTokens }) => { - getLaboratoryTokens().then((res) => { - if (res.sucesso) { - setLabTokens({ researcher: res.dados.researcher_token, auditor: res.dados.auditor_token }); - } - }); - }); + void loadLaboratoryTokens(); } - }, [user]); + }, [user?.role]); const userName = user?.name || 'Usuário'; const userEmail = user?.email || 'usuario@edtech.com'; @@ -80,6 +93,32 @@ const Settings: React.FC = () => { setIsJoining(false); }; + const handleAvatarChange = (file?: File) => { + if (!file) return; + if (!file.type.startsWith('image/')) { + setProfileMessage('Escolha uma imagem PNG, JPEG, WebP ou GIF.'); + return; + } + if (file.size > 1_500_000) { + setProfileMessage('A imagem deve ter no máximo 1,5 MB.'); + return; + } + const reader = new FileReader(); + reader.onload = () => setAvatarPreview(String(reader.result)); + reader.readAsDataURL(file); + }; + + const handleSaveProfile = async () => { + const response = await updateProfile(profileName.trim(), avatarPreview); + if (response.sucesso) { + setProfileMessage('Perfil atualizado com sucesso.'); + setIsEditingProfile(false); + await checkAuth?.(); + } else { + setProfileMessage(response.mensagem || 'Não foi possível atualizar o perfil.'); + } + }; + return ( {

Perfil

-
{userInitials}
+
{user?.avatarUrl ? Foto do perfil : userInitials}
{userName} {userEmail}
{roleLabel[user?.role || ''] || 'Conta'} - +
+ {isEditingProfile && ( +
+
+

Editar perfil

+ + setProfileName(e.target.value)} /> + +
+
{avatarPreview ? Prévia da foto : userInitials}
+ handleAvatarChange(e.target.files?.[0])} /> +
+ {profileMessage &&

{profileMessage}

} +
+
+
+ )} + {/* Notificações */}

Notificações

@@ -172,6 +228,7 @@ const Settings: React.FC = () => {
+ {setupError && !qrCodeUri &&

{setupError}

} {qrCodeUri && !is2FaEnabled && (
@@ -266,13 +323,20 @@ const Settings: React.FC = () => { )} {/* Painel do Orientador (Apenas para Orientadores) */} - {user?.role === 'ADVISOR' && labTokens && ( + {user?.role === 'ADVISOR' && (

Códigos de Acesso do Laboratório

Compartilhe os códigos abaixo com sua equipe. Por motivos de segurança, os códigos para pesquisadores e auditores são diferentes.

-
+ {isLoadingLabTokens &&

Gerando códigos de associação...

} + {labTokensError && ( +
+ {labTokensError} + +
+ )} + {labTokens &&
Token para Pesquisadores
{labTokens.researcher}
@@ -281,7 +345,7 @@ const Settings: React.FC = () => {
Token para Auditores
{labTokens.auditor}
-
+
}
)} diff --git a/frontend/src/pages/trail.tsx b/frontend/src/pages/trail.tsx index 244a23767..0e02d35bf 100644 --- a/frontend/src/pages/trail.tsx +++ b/frontend/src/pages/trail.tsx @@ -1,13 +1,13 @@ import React, { useState } from 'react'; import DashboardLayout from '../components/layout/DashboardLayout'; import { useAuth } from '../context/authContext'; -import { exportDocumentAuditTrail } from '../services/api'; import '../assets/trail.css'; const ResearchTrail: React.FC = () => { const { user } = useAuth(); // For demo purposes, we'll use local state to switch between "list mode" and "detail mode" const [selectedDocId, setSelectedDocId] = useState('1'); + const [isComparingVersions, setIsComparingVersions] = useState(false); const advisorDocuments = [ { @@ -58,14 +58,40 @@ const ResearchTrail: React.FC = () => { ]; const documents = user?.role === 'RESEARCHER' ? researcherDocuments : advisorDocuments; + const selectedDocument = documents.find((document) => document.id === selectedDocId) || documents[0]; - const handleExportTrail = async () => { - if (!selectedDocId) return; - try { - await exportDocumentAuditTrail(selectedDocId, 'pdf'); - } catch(e) { - console.error('Failed to export', e); - } + const handleExportTrail = () => { + const lines = [ + 'EDTECH - TRILHA DE PESQUISA', + `Documento: ${selectedDocument.title}`, + `Projeto: ${selectedDocument.project}`, + `Status: ${selectedDocument.status}`, + 'v4 - Documento aprovado - Hoje, 14:32', + 'v4 - Verificacao LGPD aprovada - Hoje, 14:10', + 'v4 - Nova versao criada - Hoje, 11:48', + 'v3 - Comentario do orientador - Ontem, 16:20', + 'v1 - Documento criado - 10 Jun, 09:00', + ]; + const escapePdf = (value: string) => value.replace(/[\\()]/g, '\\$&'); + const stream = `BT /F1 15 Tf 52 790 Td ${lines.map((line, index) => `${index ? '0 -26 Td ' : ''}(${escapePdf(line)}) Tj`).join(' ')} ET`; + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ]; + let pdf = '%PDF-1.4\n'; + const offsets = [0]; + objects.forEach((object, index) => { offsets.push(pdf.length); pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; }); + const xrefOffset = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; + const url = URL.createObjectURL(new Blob([pdf], { type: 'application/pdf' })); + const link = document.createElement('a'); + link.href = url; + link.download = `trilha_${selectedDocument.title.replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf`; + link.click(); + URL.revokeObjectURL(url); }; const getStatusBadgeClass = (status: string) => { @@ -142,7 +168,7 @@ const ResearchTrail: React.FC = () => {
{/* Details Section for Document 1 (Mocked as per screenshot) */} - {selectedDocId === '1' && ( + {selectedDocument && (
@@ -151,10 +177,10 @@ const ResearchTrail: React.FC = () => {
- {user?.role === 'RESEARCHER' ? 'Referencial_Teorico_Final.pdf' : 'Metodologia_Qualitativa_v3.pdf'} + {selectedDocument.title} - {user?.role === 'RESEARCHER' ? 'Seu projeto - Análise LGPD - 7 eventos · 4 versões' : 'Dra. Renata Silva - Análise LGPD - 7 eventos · 4 versões'} + {selectedDocument.project} · 7 eventos · 4 versões
@@ -360,7 +386,7 @@ const ResearchTrail: React.FC = () => {
-
@@ -390,6 +416,16 @@ const ResearchTrail: React.FC = () => { )} + + {isComparingVersions && ( +
+
+

Comparar versões

{selectedDocument.title}

+
v3 · 10 Jun

Metodologia, referências e escopo inicial.

v4 · Hoje

Inclui revisão LGPD, evidências e aprovação do orientador.

+

3 alterações de conteúdo e 1 validação de conformidade registradas.

+
+
+ )} ); }; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5548b2927..7f545e12c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -171,6 +171,15 @@ export const getMe = async (): Promise> => { } }; +export const updateProfile = async (name: string, avatarUrl?: string | null): Promise> => { + try { + const response = await api.patch('/api/auth/me', { name, avatarUrl: avatarUrl || null }); + return { sucesso: true, dados: response.data }; + } catch (error) { + return handleApiError(error, 'Não foi possível atualizar o perfil.'); + } +}; + export const getProjects = async (page = 0, size = 20): Promise> => { try { const resposta = await api.get('/api/projects', { params: { page, size } }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 460cae18e..26e5564e6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -5,13 +5,16 @@ export interface User { role: 'RESEARCHER' | 'ADVISOR' | 'AUDITOR' | 'ADMIN'; mfaEnabled?: boolean; institutionId?: string; + avatarUrl?: string | null; } export interface Project { id: string; - name: string; + name?: string; + title?: string; description: string; createdAt?: string; + member?: boolean; } export interface Document { diff --git a/infra/terraform/modules/cloud_run/main.tf b/infra/terraform/modules/cloud_run/main.tf index e87eeebe2..bf47aceab 100644 --- a/infra/terraform/modules/cloud_run/main.tf +++ b/infra/terraform/modules/cloud_run/main.tf @@ -255,6 +255,13 @@ resource "google_cloud_run_v2_job" "flyway_migration" { value = "true" } + # O banco de staging já possui uma migração de seed com versão alta. + # Permite aplicar migrações corretivas adicionadas depois sem ignorar validação. + env { + name = "SPRING_FLYWAY_OUT_OF_ORDER" + value = "true" + } + env { name = "JWT_SECRET" value_source { diff --git a/tests/api-contract/production-security-contract.mjs b/tests/api-contract/production-security-contract.mjs index e5c25f3a0..3637e8eab 100644 --- a/tests/api-contract/production-security-contract.mjs +++ b/tests/api-contract/production-security-contract.mjs @@ -7,6 +7,9 @@ const reportJsonPath = resolve(reportDirectory, 'production-security-contract.js const reportHtmlPath = resolve(reportDirectory, 'index.html'); const requestId = `ci-api-contract-${Date.now()}`; const startedAt = new Date(); +const maxAttempts = Number(process.env.API_CONTRACT_MAX_ATTEMPTS || 4); +const timeoutMs = Number(process.env.API_CONTRACT_TIMEOUT_MS || 30_000); +const retryDelayMs = Number(process.env.API_CONTRACT_RETRY_DELAY_MS || 8_000); const escapeHtml = (value) => String(value) .replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') @@ -15,6 +18,25 @@ const escapeHtml = (value) => String(value) const hasValue = (headers, name, expected) => (headers.get(name) || '').toLowerCase().includes(expected.toLowerCase()); +const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const fetchHealthWithRetry = async () => { + let lastError; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const response = await fetch(`${apiUrl}/actuator/health`, { + headers: { 'X-Request-ID': requestId }, signal: AbortSignal.timeout(timeoutMs), + }); + return { response, attempts: attempt }; + } catch (error) { + lastError = error; + console.warn(`Health check attempt ${attempt}/${maxAttempts} failed: ${error.message}`); + if (attempt < maxAttempts) await sleep(retryDelayMs * attempt); + } + } + throw new Error(`API did not respond after ${maxAttempts} attempts: ${lastError?.message || 'unknown error'}`); +}; + const renderReport = (report) => { const rows = report.checks.map((check) => `${escapeHtml(check.name)}${check.passed ? 'Passed' : 'Failed'}${escapeHtml(check.expected)}${escapeHtml(check.actual)}`).join(''); return `EdTech API Security Contract Report

EdTech API Security Contract

${report.passed ? 'All checks passed' : 'One or more checks failed'}

Endpoint: ${escapeHtml(report.endpoint)}

Executed at: ${escapeHtml(report.executedAt)}

Read-only edge security checks

${rows}
CheckResultExpectedObserved
`; @@ -24,9 +46,9 @@ const run = async () => { const checks = []; let response; try { - response = await fetch(`${apiUrl}/actuator/health`, { - headers: { 'X-Request-ID': requestId }, signal: AbortSignal.timeout(30_000), - }); + const result = await fetchHealthWithRetry(); + response = result.response; + console.log(`API health endpoint responded on attempt ${result.attempts}/${maxAttempts}.`); } catch (error) { checks.push({ name: 'API reachability for security contract inspection', passed: false, expected: 'A response from the published API', actual: error.message }); }