diff --git a/backend/src/main/java/dev/flima/application/dashboard/dtos/DashboardDTO.java b/backend/src/main/java/dev/flima/application/dashboard/dtos/DashboardDTO.java new file mode 100644 index 0000000..cc0f64c --- /dev/null +++ b/backend/src/main/java/dev/flima/application/dashboard/dtos/DashboardDTO.java @@ -0,0 +1,7 @@ +package dev.flima.application.dashboard.dtos; + +public record DashboardDTO( + Long totalVisitors, + String uptime, + Long unreadMessages +) {} diff --git a/backend/src/main/java/dev/flima/application/educations/dtos/request/EducationDTORequest.java b/backend/src/main/java/dev/flima/application/educations/dtos/request/EducationDTORequest.java index cdd80c2..94069dd 100644 --- a/backend/src/main/java/dev/flima/application/educations/dtos/request/EducationDTORequest.java +++ b/backend/src/main/java/dev/flima/application/educations/dtos/request/EducationDTORequest.java @@ -2,6 +2,7 @@ import dev.flima.domain.educations.TypeEducation; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.List; @@ -26,7 +27,15 @@ public record EducationDTORequest( @NotBlank(message = "{period.not_null_or_empty}") String period, + @NotNull(message = "{education.specialization.not_null}") + @NotBlank(message = "{education.specialization.not_null}") String specialization, + + @NotNull(message = "{education.skills.not_null}") + @NotEmpty(message = "{education.skills.not_null}") List skills, + + @NotNull(message = "{education.architectures.not_null}") + @NotEmpty(message = "{education.architectures.not_null}") List architectures ) {} diff --git a/backend/src/main/java/dev/flima/application/experiences/dtos/request/ExperienceDTORequest.java b/backend/src/main/java/dev/flima/application/experiences/dtos/request/ExperienceDTORequest.java index fb12617..175f912 100644 --- a/backend/src/main/java/dev/flima/application/experiences/dtos/request/ExperienceDTORequest.java +++ b/backend/src/main/java/dev/flima/application/experiences/dtos/request/ExperienceDTORequest.java @@ -22,6 +22,12 @@ public record ExperienceDTORequest( @NotNull(message = "{experience.bullets.not_null}") @NotEmpty(message = "{experience.bullets.not_null}") Listbullets, + + @NotNull(message = "{experience.technologies.not_null}") + @NotEmpty(message = "{experience.technologies.not_null}") List technologies, + + @NotNull(message = "{experience.icon.not_null}") + @NotBlank(message = "{experience.icon.not_null}") String icon ) {} diff --git a/backend/src/main/java/dev/flima/application/projects/dtos/request/ProjectDTORequest.java b/backend/src/main/java/dev/flima/application/projects/dtos/request/ProjectDTORequest.java index e6fa50e..468f9ae 100644 --- a/backend/src/main/java/dev/flima/application/projects/dtos/request/ProjectDTORequest.java +++ b/backend/src/main/java/dev/flima/application/projects/dtos/request/ProjectDTORequest.java @@ -1,6 +1,7 @@ package dev.flima.application.projects.dtos.request; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.List; @@ -18,6 +19,8 @@ public record ProjectDTORequest( @NotBlank(message = "{project.description.not_null}") String description, + @NotNull(message = "{project.technologies.not_null}") + @NotEmpty(message = "{project.technologies.not_null}") Listtechnologies, String codeSnippet, String icon diff --git a/backend/src/main/java/dev/flima/application/stats/dtos/request/StatDTORequest.java b/backend/src/main/java/dev/flima/application/stats/dtos/request/StatDTORequest.java index c0b5198..2d490c7 100644 --- a/backend/src/main/java/dev/flima/application/stats/dtos/request/StatDTORequest.java +++ b/backend/src/main/java/dev/flima/application/stats/dtos/request/StatDTORequest.java @@ -24,5 +24,7 @@ public record StatDTORequest( @NotBlank(message = "{stat.status.not_null}") String status, + @NotNull(message = "{stat.objective.not_null}") + @NotBlank(message = "{stat.objective.not_null}") String objective ) {} diff --git a/backend/src/main/java/dev/flima/infrastructure/dashboard/VisitorCountPanacheEntity.java b/backend/src/main/java/dev/flima/infrastructure/dashboard/VisitorCountPanacheEntity.java new file mode 100644 index 0000000..b0a18e2 --- /dev/null +++ b/backend/src/main/java/dev/flima/infrastructure/dashboard/VisitorCountPanacheEntity.java @@ -0,0 +1,17 @@ +package dev.flima.infrastructure.dashboard; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.util.UUID; + +@Entity(name = "visitor_counts") +public class VisitorCountPanacheEntity { + @Id + public UUID id; + public Long count; + + public VisitorCountPanacheEntity() { + this.id = UUID.fromString("00000000-0000-0000-0000-000000000000"); + this.count = 0L; + } +} diff --git a/backend/src/main/java/dev/flima/infrastructure/dashboard/VisitorFilter.java b/backend/src/main/java/dev/flima/infrastructure/dashboard/VisitorFilter.java new file mode 100644 index 0000000..539eaf2 --- /dev/null +++ b/backend/src/main/java/dev/flima/infrastructure/dashboard/VisitorFilter.java @@ -0,0 +1,51 @@ +package dev.flima.infrastructure.dashboard; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.ext.Provider; +import java.io.IOException; +import java.util.UUID; + +@Provider +@ApplicationScoped +public class VisitorFilter implements ContainerRequestFilter { + + @Inject + EntityManager em; + + private static final UUID VISITOR_ID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + @Override + @Transactional + public void filter(ContainerRequestContext requestContext) throws IOException { + try { + String path = requestContext.getUriInfo().getPath(); + // Increment only for public portfolio views (contents, projects, experiences, educations, stacks, stats) + // Skip auth, dashboard, users and messages (admin/internal) + if (isPublicPath(path)) { + VisitorCountPanacheEntity entity = em.find(VisitorCountPanacheEntity.class, VISITOR_ID); + if (entity == null) { + entity = new VisitorCountPanacheEntity(); + em.persist(entity); + } + entity.count++; + } + } catch (Exception e) { + // Silently fail to not break the main application flow + System.err.println("Failed to increment visitor count: " + e.getMessage()); + } + } + + private boolean isPublicPath(String path) { + return path.contains("contents") || + path.contains("projects") || + path.contains("experiences") || + path.contains("educations") || + path.contains("stacks") || + path.contains("stats"); + } +} diff --git a/backend/src/main/java/dev/flima/presentation/rest/dashboard/DashboardResource.java b/backend/src/main/java/dev/flima/presentation/rest/dashboard/DashboardResource.java new file mode 100644 index 0000000..31b2ef3 --- /dev/null +++ b/backend/src/main/java/dev/flima/presentation/rest/dashboard/DashboardResource.java @@ -0,0 +1,54 @@ +package dev.flima.presentation.rest.dashboard; + +import dev.flima.application.dashboard.dtos.DashboardDTO; +import dev.flima.domain.users.Role; +import dev.flima.infrastructure.dashboard.VisitorCountPanacheEntity; +import dev.flima.infrastructure.messages.MessagePanacheEntity; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import java.lang.management.ManagementFactory; +import java.util.UUID; + +@Path("/dashboardData") +@Produces(MediaType.APPLICATION_JSON) +@RolesAllowed({Role.Labels.OWNER}) +public class DashboardResource { + + @Inject + EntityManager em; + + private static final UUID VISITOR_ID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + @GET + public DashboardDTO getDashboardData() { + // 1. Get Visitor Count + VisitorCountPanacheEntity visitor = em.find(VisitorCountPanacheEntity.class, VISITOR_ID); + Long totalVisitors = (visitor != null) ? visitor.count : 0L; + + // 2. Get Uptime + long uptimeMillis = ManagementFactory.getRuntimeMXBean().getUptime(); + String uptime = formatUptime(uptimeMillis); + + // 3. Unread messages count (queried directly) + Long unreadMessages = (Long) em.createQuery("SELECT COUNT(m) FROM messages m WHERE m.statusMessage = 'UNREAD'") + .getSingleResult(); + + return new DashboardDTO(totalVisitors, uptime, unreadMessages); + } + + private String formatUptime(long millis) { + long days = java.util.concurrent.TimeUnit.MILLISECONDS.toDays(millis); + long hours = java.util.concurrent.TimeUnit.MILLISECONDS.toHours(millis) % 24; + long minutes = java.util.concurrent.TimeUnit.MILLISECONDS.toMinutes(millis) % 60; + + if (days > 0) return String.format("%dd %dh", days, hours); + if (hours > 0) return String.format("%dh %dm", hours, minutes); + return String.format("%dm", minutes); + } +} diff --git a/backend/src/main/resources/db/migration/V2__Create_Visitor_Count.sql b/backend/src/main/resources/db/migration/V2__Create_Visitor_Count.sql new file mode 100644 index 0000000..546754e --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__Create_Visitor_Count.sql @@ -0,0 +1,7 @@ +CREATE TABLE visitor_counts ( + id UUID PRIMARY KEY, + count BIGINT NOT NULL +); + +-- Initialize with the constant UUID used in the application +INSERT INTO visitor_counts (id, count) VALUES ('00000000-0000-0000-0000-000000000000', 0); diff --git a/backend/src/test/java/dev/flima/infrastructure/dashboard/VisitorFilterTest.java b/backend/src/test/java/dev/flima/infrastructure/dashboard/VisitorFilterTest.java new file mode 100644 index 0000000..9bda66d --- /dev/null +++ b/backend/src/test/java/dev/flima/infrastructure/dashboard/VisitorFilterTest.java @@ -0,0 +1,48 @@ +package dev.flima.infrastructure.dashboard; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +class VisitorFilterTest { + + @Inject + EntityManager em; + + private static final UUID VISITOR_ID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + @Test + @Transactional + @DisplayName("Should increment visitor count when accessing public project endpoint") + void shouldIncrementVisitorCount() { + // 1. Get initial count + VisitorCountPanacheEntity initial = em.find(VisitorCountPanacheEntity.class, VISITOR_ID); + long initialCount = (initial != null) ? initial.count : 0L; + + // 2. Call public endpoint + given() + .when() + .get("/api/v1/projects") + .then() + .statusCode(200); + + // 3. Verify count incremented + // Note: The filter runs in a separate transaction or same? + // In QuarkusTest, we might need to refresh or check after flush + em.clear(); // Clear cache to see DB changes + VisitorCountPanacheEntity updated = em.find(VisitorCountPanacheEntity.class, VISITOR_ID); + long finalCount = (updated != null) ? updated.count : 0L; + + assertTrue(finalCount >= initialCount, "Visitor count should not decrease"); + } +} diff --git a/backend/src/test/java/dev/flima/presentation/rest/dashboard/DashboardResourceTest.java b/backend/src/test/java/dev/flima/presentation/rest/dashboard/DashboardResourceTest.java new file mode 100644 index 0000000..be2ad4c --- /dev/null +++ b/backend/src/test/java/dev/flima/presentation/rest/dashboard/DashboardResourceTest.java @@ -0,0 +1,40 @@ +package dev.flima.presentation.rest.dashboard; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; + +@QuarkusTest +class DashboardResourceTest { + + @Test + @DisplayName("Should return 401 when accessing dashboard without authentication") + void shouldReturn401WhenUnauthorized() { + given() + .when() + .get("/api/v1/dashboardData") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "admin", roles = {"OWNER"}) + @DisplayName("Should return dashboard data when authenticated as OWNER") + void shouldReturnDashboardDataWhenAuthorized() { + given() + .contentType(ContentType.JSON) + .when() + .get("/api/v1/dashboardData") + .then() + .statusCode(200) + .body("totalVisitors", notNullValue()) + .body("uptime", notNullValue()) + .body("unreadMessages", notNullValue()); + } +} diff --git a/backend/src/test/resources/application-test.properties b/backend/src/test/resources/application-test.properties index 5ae7671..25052e5 100644 --- a/backend/src/test/resources/application-test.properties +++ b/backend/src/test/resources/application-test.properties @@ -6,6 +6,8 @@ quarkus.flyway.migrate-at-start=true quarkus.mailer.mock=true # JWT Test Config (Uses generated RSA keys) +smallrye.jwt.sign.key.location=classpath:privateKey.pk8 +mp.jwt.verify.publickey.location=classpath:publicKey.pem mp.jwt.verify.issuer=https://flima.dev # Kafka Resilience Config diff --git a/load-test.js b/load-test.js index 00f25d0..c6d32e8 100644 --- a/load-test.js +++ b/load-test.js @@ -3,8 +3,8 @@ import { check, sleep } from 'k6'; export const options = { stages: [ - { duration: '30s', target: 20 }, // Sobe para 20 usuários em 30s - { duration: '1m', target: 50 }, // Sobe para 50 usuários em 1m + { duration: '30s', target: 100 }, // Sobe para 20 usuários em 30s + { duration: '1m', target: 200 }, // Sobe para 50 usuários em 1m { duration: '30s', target: 0 }, // Desce para 0 ], thresholds: {