diff --git a/.gitignore b/.gitignore index 5efca23..451f097 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ .vscode .env *.pem +*.sql +*.sql.gz diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..9fc0f4a --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,47 @@ +# Diretrizes do Projeto BeeTask (GEMINI.md) + +Este documento contém as instruções e regras mandatórias para desenvolvimento e manutenção no repositório **bee-task-v3**. + +--- + +## ⚠️ Regra Mandatória: Commit e Push ao Finalizar Edições + +> [!IMPORTANT] +> **Sempre após a finalização de quaisquer edições ou correções no código, é OBRIGATÓRIO realizar o `git commit` com uma mensagem clara e padronizada e em seguida executar o `git push` para o repositório remoto.** + +### Fluxo Obrigatório pós-edição: +1. Validar as alterações (testes/build, ex: `tsc -b` ou `./mvnw test` se aplicável). +2. Adicionar os arquivos alterados: + ```bash + git add + ``` +3. Fazer o commit semântico: + ```bash + git commit -m "feat/fix/refactor: descrição clara da alteração" + ``` +4. Fazer o push imediatamente: + ```bash + git push origin + ``` + +--- + +## 🐳 Atualização de Ambiente Docker + +Caso as alterações envolvam o frontend ou backend que rodam via Docker Compose em produção/staging: +- **Frontend**: + ```bash + docker compose build --no-cache frontend && docker compose up -d --no-deps frontend + ``` +- **Backend**: + ```bash + docker compose build --no-cache backend && docker compose up -d --no-deps backend + ``` + +--- + +## 🛠️ Padrões do Projeto + +- **Frontend**: Vite + React 19 + TypeScript + Tailwind v4 + HeroUI. + - Verificar tipagem estrita com `npm --prefix frontend exec -- tsc -b`. +- **Backend**: Quarkus 3.34 + Java 17 + Clean Architecture (Domain Entities -> Use Cases -> Interface Adapters -> Frameworks). diff --git a/backend/pom.xml b/backend/pom.xml index 0a69cf4..18a03d0 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -84,6 +84,11 @@ minio 8.5.17 + + org.mindrot + jbcrypt + 0.4 + io.quarkus quarkus-junit diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java b/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java index b11c02c..1bddee2 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java @@ -8,19 +8,38 @@ import java.util.Objects; import net.beetechgroup.beetask.entities.Category; import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.entities.organization.Organization; public class Task { private Long id; private String title; private String description; private TaskStatus status; + private Organization organization; private Project project; private Category category; private User user; + private User createdBy; private LocalDateTime finishedAt; private List history = new ArrayList<>(); private String externalUrl; + public Organization getOrganization() { + return organization; + } + + public void setOrganization(Organization organization) { + this.organization = organization; + } + + public User getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(User createdBy) { + this.createdBy = createdBy; + } + public String getExternalUrl() { return externalUrl; } @@ -60,8 +79,15 @@ public class Task { public void setStatus(TaskStatus status) { if (this.status == status) return; - if (status == TaskStatus.COMPLETED) { - this.finishedAt = LocalDateTime.now(); + if (status == TaskStatus.COMPLETED || status == TaskStatus.CANCELED) { + if (isRunning()) { + this.stop(); + } + if (status == TaskStatus.COMPLETED) { + this.finishedAt = LocalDateTime.now(); + } else { + this.finishedAt = null; + } } else { this.finishedAt = null; } @@ -121,6 +147,10 @@ public class Task { } public void start() { + start(this.user); + } + + public void start(User worker) { TaskHistoryItem current = getCurrentRunningItem(); if (Objects.nonNull(current)) { @@ -130,6 +160,7 @@ public class Task { this.status = TaskStatus.IN_PROGRESS; TaskHistoryItem item = new TaskHistoryItem(); item.setStartAt(LocalDateTime.now()); + item.setUser(worker != null ? worker : this.user); this.history.add(item); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskHistoryItem.java b/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskHistoryItem.java index c95d9ab..37936a5 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskHistoryItem.java +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskHistoryItem.java @@ -8,6 +8,15 @@ public class TaskHistoryItem { private Long id; private LocalDateTime startAt; private LocalDateTime endAt; + private net.beetechgroup.beetask.entities.User user; + + public net.beetechgroup.beetask.entities.User getUser() { + return user; + } + + public void setUser(net.beetechgroup.beetask.entities.User user) { + this.user = user; + } public Long getId() { return id; diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/CategoryUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/CategoryUseCaseConfig.java index d625825..71c8d05 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/CategoryUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/CategoryUseCaseConfig.java @@ -6,6 +6,7 @@ import net.beetechgroup.beetask.usecase.category.create.CreateCategoryUseCase; import net.beetechgroup.beetask.usecase.category.list.ListCategoriesUseCase; import net.beetechgroup.beetask.usecase.category.update.UpdateCategoryUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; @@ -26,7 +27,9 @@ public class CategoryUseCaseConfig { } @Produces - public ListCategoriesUseCase listCategoriesUseCase(CategoryRepository categoryRepository) { - return new ListCategoriesUseCase(categoryRepository); + public ListCategoriesUseCase listCategoriesUseCase(CategoryRepository categoryRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new ListCategoriesUseCase(categoryRepository, authorizer); } } + diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/OrganizationUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/OrganizationUseCaseConfig.java index c5edefa..cf88a7e 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/OrganizationUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/OrganizationUseCaseConfig.java @@ -9,6 +9,7 @@ import net.beetechgroup.beetask.usecase.organization.requests.ListPendingRequest import net.beetechgroup.beetask.usecase.organization.requests.ListUserJoinRequestsUseCase; import net.beetechgroup.beetask.usecase.organization.requests.HandleJoinRequestUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; @@ -23,6 +24,12 @@ public class OrganizationUseCaseConfig { return new AuthorizeOrganizationAdminUseCase(userRepository, userOrganizationRepository); } + @Produces + public AuthorizeOrganizationMemberUseCase authorizeOrganizationMemberUseCase(UserRepository userRepository, + UserOrganizationRepository userOrganizationRepository) { + return new AuthorizeOrganizationMemberUseCase(userRepository, userOrganizationRepository); + } + @Produces public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository, UserRepository userRepository, @@ -65,4 +72,24 @@ public class OrganizationUseCaseConfig { UserOrganizationRepository userOrganizationRepository) { return new ListOrganizationMembersUseCase(userRepository, userOrganizationRepository); } + + @Produces + public net.beetechgroup.beetask.usecase.organization.members.RemoveOrganizationMemberUseCase removeOrganizationMemberUseCase( + UserOrganizationRepository userOrganizationRepository, + UserRepository userRepository, + AuthorizeOrganizationAdminUseCase adminAuthorizer, + jakarta.persistence.EntityManager entityManager) { + return new net.beetechgroup.beetask.usecase.organization.members.RemoveOrganizationMemberUseCase( + userOrganizationRepository, userRepository, adminAuthorizer, entityManager); + } + + @Produces + public net.beetechgroup.beetask.usecase.organization.members.LeaveOrganizationUseCase leaveOrganizationUseCase( + UserOrganizationRepository userOrganizationRepository, + UserRepository userRepository, + AuthorizeOrganizationMemberUseCase memberAuthorizer, + jakarta.persistence.EntityManager entityManager) { + return new net.beetechgroup.beetask.usecase.organization.members.LeaveOrganizationUseCase( + userOrganizationRepository, userRepository, memberAuthorizer, entityManager); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ProjectUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ProjectUseCaseConfig.java index 3f966dd..55b4f3c 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ProjectUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ProjectUseCaseConfig.java @@ -2,6 +2,7 @@ package net.beetechgroup.beetask.frameworks.config; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.project.create.CreateProjectUseCase; import net.beetechgroup.beetask.usecase.project.list.ListProjectsUseCase; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; @@ -12,12 +13,29 @@ public class ProjectUseCaseConfig { @Produces public CreateProjectUseCase createProjectUseCase(ProjectRepository projectRepository, - OrganizationRepository organizationRepository) { - return new CreateProjectUseCase(projectRepository, organizationRepository); + OrganizationRepository organizationRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new CreateProjectUseCase(projectRepository, organizationRepository, authorizer); } @Produces - public ListProjectsUseCase listProjectsUseCase(ProjectRepository projectRepository) { - return new ListProjectsUseCase(projectRepository); + public ListProjectsUseCase listProjectsUseCase(ProjectRepository projectRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new ListProjectsUseCase(projectRepository, authorizer); + } + + @Produces + public net.beetechgroup.beetask.usecase.project.update.UpdateProjectUseCase updateProjectUseCase( + ProjectRepository projectRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new net.beetechgroup.beetask.usecase.project.update.UpdateProjectUseCase(projectRepository, authorizer); + } + + @Produces + public net.beetechgroup.beetask.usecase.project.delete.DeleteProjectUseCase deleteProjectUseCase( + ProjectRepository projectRepository, + net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase authorizer) { + return new net.beetechgroup.beetask.usecase.project.delete.DeleteProjectUseCase(projectRepository, authorizer); } } + diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java index 1903f17..5d3f66c 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java @@ -2,6 +2,7 @@ package net.beetechgroup.beetask.frameworks.config; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository; @@ -22,18 +23,19 @@ public class TaskUseCaseConfig { @Produces public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, CategoryRepository categoryRepository, UserRepository userRepository, - UserOrganizationRepository userOrganizationRepository) { - return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository); + UserOrganizationRepository userOrganizationRepository, + net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) { + return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository); } @Produces - public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository) { - return new StartTaskUseCase(taskRepository, userRepository); + public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) { + return new StartTaskUseCase(taskRepository, userRepository, authorizer); } @Produces - public StopTaskUseCase stopTaskUseCase(TaskRepository taskRepository) { - return new StopTaskUseCase(taskRepository); + public StopTaskUseCase stopTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) { + return new StopTaskUseCase(taskRepository, authorizer); } @Produces @@ -47,19 +49,29 @@ public class TaskUseCaseConfig { } @Produces - public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository) { - return new UpdateTaskStatusUseCase(taskRepository); + public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) { + return new UpdateTaskStatusUseCase(taskRepository, authorizer); } @Produces public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, CategoryRepository categoryRepository, UserRepository userRepository, - UserOrganizationRepository userOrganizationRepository) { - return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository); + UserOrganizationRepository userOrganizationRepository, + net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) { + return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository); } @Produces - public GetTaskUseCase getTaskUseCase(TaskRepository taskRepository) { - return new GetTaskUseCase(taskRepository); + public GetTaskUseCase getTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) { + return new GetTaskUseCase(taskRepository, authorizer); + } + + @Produces + public net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase deleteTaskUseCase( + TaskRepository taskRepository, + UserRepository userRepository, + UserOrganizationRepository userOrganizationRepository) { + return new net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase(taskRepository, userRepository, userOrganizationRepository); } } + diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java index f5ccdd4..b1335cd 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java @@ -63,4 +63,9 @@ public class UserUseCaseConfig { public net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase updateUserPreferencesUseCase(UserRepository userRepository) { return new net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase(userRepository); } + + @Produces + public net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase listAllUsersUseCase(UserRepository userRepository) { + return new net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase(userRepository); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/CategoryEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/CategoryEntity.java index 51d00ec..f2c38a0 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/CategoryEntity.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/CategoryEntity.java @@ -7,9 +7,14 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.Column; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; @Entity @Table(name = "categories") +@SQLDelete(sql = "UPDATE categories SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") public class CategoryEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -17,6 +22,8 @@ public class CategoryEntity { private String name; private String color; private String icon; + @Column(name = "deleted_at") + private java.time.LocalDateTime deletedAt; @ManyToOne @JoinColumn(name = "organization_id") @@ -61,4 +68,12 @@ public class CategoryEntity { public void setOrganization(OrganizationEntity organization) { this.organization = organization; } + + public java.time.LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(java.time.LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/OrganizationEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/OrganizationEntity.java index 1a161dd..6f9359a 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/OrganizationEntity.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/OrganizationEntity.java @@ -8,14 +8,21 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.Column; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; @Entity @Table(name = "organizations") +@SQLDelete(sql = "UPDATE organizations SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") public class OrganizationEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; + @Column(name = "deleted_at") + private java.time.LocalDateTime deletedAt; @OneToMany(mappedBy = "organization") private List userOrganizations; @@ -43,4 +50,11 @@ public class OrganizationEntity { this.name = name; } + public java.time.LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(java.time.LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ProjectEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ProjectEntity.java index a0fd6d0..fe48265 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ProjectEntity.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ProjectEntity.java @@ -11,9 +11,14 @@ import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.Column; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; @Entity @Table(name = "projects") +@SQLDelete(sql = "UPDATE projects SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") public class ProjectEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -21,6 +26,8 @@ public class ProjectEntity { private String name; private String color; private String icon; + @Column(name = "deleted_at") + private java.time.LocalDateTime deletedAt; @ManyToOne @JoinColumn(name = "organization_id") private OrganizationEntity organization; @@ -76,4 +83,12 @@ public class ProjectEntity { public void setUsers(List users) { this.users = users; } + + public java.time.LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(java.time.LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java index c8750c1..ad80d58 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java @@ -14,10 +14,14 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; import net.beetechgroup.beetask.entities.task.TaskStatus; @Entity @Table(name = "tasks") +@SQLDelete(sql = "UPDATE tasks SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") public class TaskEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -30,7 +34,12 @@ public class TaskEntity { private java.time.LocalDateTime finishedAt; @Column(name = "external_url", length = 1000) private String externalUrl; + @Column(name = "deleted_at") + private java.time.LocalDateTime deletedAt; + @ManyToOne + @JoinColumn(name = "organization_id") + private OrganizationEntity organization; @ManyToOne @JoinColumn(name = "project_id") private ProjectEntity project; @@ -40,9 +49,28 @@ public class TaskEntity { @ManyToOne @JoinColumn(name = "user_id") private UserEntity user; - @OneToMany(mappedBy = "task", cascade = CascadeType.ALL, orphanRemoval = true) + @ManyToOne + @JoinColumn(name = "created_by_id") + private UserEntity createdBy; + @OneToMany(mappedBy = "task", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private List history; + public OrganizationEntity getOrganization() { + return organization; + } + + public void setOrganization(OrganizationEntity organization) { + this.organization = organization; + } + + public UserEntity getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(UserEntity createdBy) { + this.createdBy = createdBy; + } + public void setHistory(List history) { this.history = history; } @@ -122,4 +150,12 @@ public class TaskEntity { public void setExternalUrl(String externalUrl) { this.externalUrl = externalUrl; } + + public java.time.LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(java.time.LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskHistoryItemEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskHistoryItemEntity.java index 2516acf..ed633a4 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskHistoryItemEntity.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskHistoryItemEntity.java @@ -27,6 +27,18 @@ public class TaskHistoryItemEntity { @JoinColumn(name = "task_id") private TaskEntity task; + @ManyToOne + @JoinColumn(name = "user_id") + private UserEntity user; + + public UserEntity getUser() { + return user; + } + + public void setUser(UserEntity user) { + this.user = user; + } + public Long getId() { return id; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserEntity.java index b8d7457..f8ee5e4 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserEntity.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserEntity.java @@ -5,9 +5,14 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.Column; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; @Entity @Table(name = "users_entity") +@SQLDelete(sql = "UPDATE users_entity SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -16,6 +21,8 @@ public class UserEntity { private String email; private String password; private String photo; + @Column(name = "deleted_at") + private java.time.LocalDateTime deletedAt; @jakarta.persistence.Column(columnDefinition = "jsonb") @org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON) private String preferences; @@ -67,4 +74,12 @@ public class UserEntity { public void setPreferences(String preferences) { this.preferences = preferences; } + + public java.time.LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(java.time.LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserOrganizationEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserOrganizationEntity.java index f4a80bd..d5e1dc8 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserOrganizationEntity.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserOrganizationEntity.java @@ -1,5 +1,6 @@ package net.beetechgroup.beetask.frameworks.persistence.entities; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -11,9 +12,15 @@ import net.beetechgroup.beetask.entities.organization.UserOrganizationRole; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; @Entity @Table(name = "user_organizations") +@SQLDelete(sql = "UPDATE user_organizations SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") public class UserOrganizationEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -28,6 +35,16 @@ public class UserOrganizationEntity { private UserOrganizationRole role; @Enumerated(EnumType.STRING) private UserOrganizationStatus status; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } public UserEntity getUser() { return user; @@ -60,4 +77,12 @@ public class UserOrganizationEntity { public void setStatus(UserOrganizationStatus status) { this.status = status; } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/OrganizationEntityMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/OrganizationEntityMapper.java index 90ddde3..95211fd 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/OrganizationEntityMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/OrganizationEntityMapper.java @@ -6,6 +6,7 @@ import net.beetechgroup.beetask.frameworks.persistence.entities.OrganizationEnti public class OrganizationEntityMapper { public static OrganizationEntity toEntity(Organization organization) { + if (Objects.isNull(organization)) return null; OrganizationEntity entity = new OrganizationEntity(); entity.setId(organization.getId()); entity.setName(organization.getName()); diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java index a7c4794..f81fb1e 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java @@ -17,9 +17,11 @@ public class TaskEntityMapper { entity.setStatus(task.getStatus()); entity.setFinishedAt(task.getFinishedAt()); entity.setExternalUrl(task.getExternalUrl()); + entity.setOrganization(OrganizationEntityMapper.toEntity(task.getOrganization())); entity.setProject(ProjectEntityMapper.toEntity(task.getProject())); entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory())); entity.setUser(UserEntityMapper.toEntity(task.getUser())); + entity.setCreatedBy(UserEntityMapper.toEntity(task.getCreatedBy())); entity.setHistory(new ArrayList<>(task.getHistory().stream().map(h -> { TaskHistoryItemEntity he = TaskHistoryItemMapper.toEntity(h); he.setTask(entity); @@ -38,9 +40,11 @@ public class TaskEntityMapper { task.setStatus(entity.getStatus()); task.setFinishedAt(entity.getFinishedAt()); task.setExternalUrl(entity.getExternalUrl()); + task.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization())); task.setProject(ProjectEntityMapper.toDomain(entity.getProject())); task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory())); task.setUser(UserEntityMapper.toDomain(entity.getUser())); + task.setCreatedBy(UserEntityMapper.toDomain(entity.getCreatedBy())); task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList())); return task; diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskHistoryItemMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskHistoryItemMapper.java index dbbd11b..8d7b892 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskHistoryItemMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskHistoryItemMapper.java @@ -10,6 +10,9 @@ public class TaskHistoryItemMapper { entity.setId(item.getId()); entity.setStartAt(item.getStartAt()); entity.setEndAt(item.getEndAt()); + if (Objects.nonNull(item.getUser())) { + entity.setUser(UserEntityMapper.toEntity(item.getUser())); + } return entity; } @@ -20,6 +23,9 @@ public class TaskHistoryItemMapper { item.setId(entity.getId()); item.setStartAt(entity.getStartAt()); item.setEndAt(entity.getEndAt()); + if (Objects.nonNull(entity.getUser())) { + item.setUser(UserEntityMapper.toDomain(entity.getUser())); + } return item; } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/OrganizationRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/OrganizationRepositoryImpl.java index 1505694..277b9cf 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/OrganizationRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/OrganizationRepositoryImpl.java @@ -41,7 +41,7 @@ public class OrganizationRepositoryImpl implements OrganizationRepository, Panac return organizations; } - List organizations = find("name like ?1", "%" + query + "%").stream() + List organizations = find("lower(name) like ?1", "%" + query.toLowerCase() + "%").stream() .map(OrganizationEntityMapper::toDomain) .toList(); LOGGER.infof("Loaded %d organizations for search query '%s'", organizations.size(), query); diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ProjectRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ProjectRepositoryImpl.java index a611e71..8158ba7 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ProjectRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ProjectRepositoryImpl.java @@ -49,4 +49,24 @@ public class ProjectRepositoryImpl implements ProjectRepository, PanacheReposito LOGGER.infof("Loaded %d projects for organization %d from database", projects.size(), organizationId); return projects; } + + @Override + public Optional findByNameAndOrganizationId(String name, Long organizationId) { + return find("lower(name) = lower(?1) and organization.id = ?2", name.trim(), organizationId) + .firstResultOptional() + .map(ProjectEntityMapper::toDomain); + } + + @Override + @Transactional + public void deleteProject(Long id) { + ProjectEntity entity = findByIdOptional(id).orElse(null); + if (entity != null) { + getEntityManager().createQuery("UPDATE TaskEntity t SET t.project = NULL WHERE t.project.id = :pid") + .setParameter("pid", id) + .executeUpdate(); + delete(entity); + LOGGER.infof("Project %d soft deleted and unlinked from tasks", id); + } + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java index 624d71e..49f1aeb 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java @@ -57,8 +57,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findAccessibleTasksFiltered(Long organizationId, String text, List projectIds, List statuses, List categoryIds, List userIds) { StringBuilder query = new StringBuilder( - "select distinct t from TaskEntity t join t.project p " + - "where p.organization.id = ?1" + "select distinct t from TaskEntity t left join t.project p left join t.category c " + + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1)" ); List params = new ArrayList<>(); params.add(organizationId); @@ -100,7 +100,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksByUserFiltered(String email, String text, List projectIds, List statuses, List categoryIds) { - StringBuilder query = new StringBuilder("user.email = ?1"); + StringBuilder query = new StringBuilder("(user.email = ?1 or (user is null and createdBy.email = ?1))"); List params = new ArrayList<>(); params.add(email); @@ -134,9 +134,29 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findRunningTasksByUserEmail(String email) { + return find("select distinct t from TaskEntity t left join t.user tu join t.history h left join h.user hu " + + "where (tu.email = ?1 or hu.email = ?1) and h.endAt is null", email) + .list().stream().map(TaskEntityMapper::toDomain).toList(); + } + @Override public List findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) { - return find("user.email = ?1", email).list().stream() + return findTasksWorkedByUserInPeriod(email, start, end, null); + } + + @Override + public List findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId) { + StringBuilder query = new StringBuilder("select distinct t from TaskEntity t " + + "left join t.project p left join t.category c where (t.user.email = ?1 or (t.user is null and t.createdBy.email = ?1))"); + List params = new ArrayList<>(); + params.add(email); + if (organizationId != null) { + query.append(" and (t.organization.id = ?2 or p.organization.id = ?2 or c.organization.id = ?2)"); + params.add(organizationId); + } + return find(query.toString(), params.toArray()).list().stream() .filter(entity -> entity.getHistory().stream().anyMatch(h -> Objects.nonNull(h.getStartAt()) && !h.getStartAt().isAfter(end) && @@ -161,15 +181,18 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) { - return find("select distinct t from TaskEntity t join t.history h " + - "where t.project.organization.id = ?1 " + + return find("select distinct t from TaskEntity t " + + "left join t.project p left join t.category c join t.history h " + + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " + "and h.startAt <= ?3 and (h.endAt is null or h.endAt >= ?2)", orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList(); } @Override public List findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) { - return find("select distinct t from TaskEntity t where t.project.organization.id = ?1 " + + return find("select distinct t from TaskEntity t " + + "left join t.project p left join t.category c " + + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " + "and t.status = net.beetechgroup.beetask.entities.task.TaskStatus.COMPLETED " + "and t.finishedAt >= ?2 and t.finishedAt <= ?3", orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList(); @@ -186,6 +209,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksWorkedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end) { + return find("select distinct t from TaskEntity t " + + "left join t.project p left join t.category c " + + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) and t.user.id = ?2", + orgId, userId).list().stream() + .filter(entity -> entity.getHistory().stream().anyMatch(h -> + Objects.nonNull(h.getStartAt()) && + !h.getStartAt().isAfter(end) && + (Objects.isNull(h.getEndAt()) || !h.getEndAt().isBefore(start)))) + .map(TaskEntityMapper::toDomain) + .toList(); + } + @Override public List findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { return find("user.id = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4", @@ -193,6 +230,16 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksFinishedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end) { + return find("select distinct t from TaskEntity t " + + "left join t.project p left join t.category c " + + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " + + "and t.user.id = ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5", + orgId, userId, TaskStatus.COMPLETED, start, end) + .list().stream().map(TaskEntityMapper::toDomain).toList(); + } + @Override public List findTasksWorkedByUserEmailsInPeriod(List emails, LocalDateTime start, LocalDateTime end) { if (emails.isEmpty()) { @@ -208,6 +255,24 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksWorkedByUserEmailsInPeriod(Long orgId, List emails, LocalDateTime start, LocalDateTime end) { + if (emails.isEmpty()) { + LOGGER.warn("Task search by user emails skipped because the email list is empty"); + return List.of(); + } + return find("select distinct t from TaskEntity t " + + "left join t.project p left join t.category c " + + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) and t.user.email in ?2", + orgId, emails).list().stream() + .filter(entity -> entity.getHistory().stream().anyMatch(h -> + Objects.nonNull(h.getStartAt()) && + !h.getStartAt().isAfter(end) && + (Objects.isNull(h.getEndAt()) || !h.getEndAt().isBefore(start)))) + .map(TaskEntityMapper::toDomain) + .toList(); + } + @Override public List findTasksFinishedByUserEmailsInPeriod(List emails, LocalDateTime start, LocalDateTime end) { if (emails.isEmpty()) { @@ -219,6 +284,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksFinishedByUserEmailsInPeriod(Long orgId, List emails, LocalDateTime start, LocalDateTime end) { + if (emails.isEmpty()) { + LOGGER.warn("Finished task search by user emails skipped because the email list is empty"); + return List.of(); + } + return find("select distinct t from TaskEntity t " + + "left join t.project p left join t.category c " + + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " + + "and t.user.email in ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5", + orgId, emails, TaskStatus.COMPLETED, start, end) + .list().stream().map(TaskEntityMapper::toDomain).toList(); + } + @Override public long countByStatus(TaskStatus status) { return count("status", status); @@ -226,10 +305,32 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository task.getHistory().stream()) - .filter(h -> Objects.nonNull(h.getEndAt())) - .mapToLong(h -> Duration.between(h.getStartAt(), h.getEndAt()).toMinutes()) - .sum(); + Object result = getEntityManager().createNativeQuery( + "SELECT COALESCE(SUM(EXTRACT(EPOCH FROM (h.end_at - h.start_at)) / 60), 0) " + + "FROM task_history h JOIN tasks t ON t.id = h.task_id " + + "WHERE h.end_at IS NOT NULL AND t.deleted_at IS NULL" + ).getSingleResult(); + return ((Number) result).longValue(); + } + + @Override + @Transactional + public void deleteTask(Long id) { + TaskEntity entity = find("id = ?1", id).firstResultOptional() + .orElseThrow(() -> { + LOGGER.warnf("Task %d was not found for deletion", id); + return new TaskNotFoundException(id); + }); + if (entity.getHistory() != null) { + for (var h : entity.getHistory()) { + if (h.getEndAt() == null) { + h.setEndAt(LocalDateTime.now()); + } + } + } + entity.setDeletedAt(LocalDateTime.now()); + getEntityManager().merge(entity); + getEntityManager().flush(); + LOGGER.infof("Task %d soft deleted from database", id); } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserOrganizationRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserOrganizationRepositoryImpl.java index e2c7099..08d132d 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserOrganizationRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserOrganizationRepositoryImpl.java @@ -77,4 +77,14 @@ public class UserOrganizationRepositoryImpl implements UserOrganizationRepositor LOGGER.infof("Created membership for user %d in organization %d with role %s and status %s", entity.getUser().getId(), entity.getOrganization().getId(), entity.getRole(), entity.getStatus()); } + + @Override + @Transactional + public void deleteByUserAndOrganization(Long userId, Long organizationId) { + Optional existing = find("user.id = ?1 and organization.id = ?2", userId, organizationId).firstResultOptional(); + existing.ifPresent(entity -> { + delete(entity); + LOGGER.infof("Soft deleted membership for user %d in organization %d", userId, organizationId); + }); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserRepositoryImpl.java index 699f744..d732844 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserRepositoryImpl.java @@ -69,4 +69,13 @@ public class UserRepositoryImpl implements UserRepository, PanacheRepository findAllUsers() { + List users = list("ORDER BY id ASC").stream() + .map(UserEntityMapper::toDomain) + .toList(); + LOGGER.infof("Loaded %d users", users.size()); + return users; + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/security/PasswordMigrationStartup.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/security/PasswordMigrationStartup.java new file mode 100644 index 0000000..97c6032 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/security/PasswordMigrationStartup.java @@ -0,0 +1,43 @@ +package net.beetechgroup.beetask.frameworks.security; + +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import net.beetechgroup.beetask.frameworks.persistence.entities.UserEntity; +import org.jboss.logging.Logger; +import org.mindrot.jbcrypt.BCrypt; + +import java.util.List; + +@ApplicationScoped +public class PasswordMigrationStartup { + private static final Logger LOGGER = Logger.getLogger(PasswordMigrationStartup.class); + + @Inject + EntityManager em; + + @Transactional + public void onStart(@Observes StartupEvent ev) { + LOGGER.info("Checking for unhashed user passwords for active members to migrate to BCrypt..."); + List users = em.createQuery( + "SELECT DISTINCT u FROM UserEntity u WHERE u.id IN (SELECT uo.user.id FROM UserOrganizationEntity uo)", + UserEntity.class).getResultList(); + int migrated = 0; + for (UserEntity user : users) { + String pwd = user.getPassword(); + if (pwd != null && !pwd.startsWith("$2a$") && !pwd.startsWith("$2b$") && !pwd.startsWith("$2y$")) { + user.setPassword(BCrypt.hashpw(pwd, BCrypt.gensalt(10))); + em.merge(user); + migrated++; + } + } + if (migrated > 0) { + LOGGER.infof("Migrated %d user passwords to BCrypt successfully", migrated); + } else { + LOGGER.info("All user passwords are already hashed with BCrypt"); + } + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/IllegalArgumentExceptionMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/IllegalArgumentExceptionMapper.java new file mode 100644 index 0000000..9b2553c --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/IllegalArgumentExceptionMapper.java @@ -0,0 +1,22 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +import java.util.Map; + +@Provider +public class IllegalArgumentExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(IllegalArgumentException exception) { + return Response.status(Response.Status.BAD_REQUEST) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of( + "error", "Bad Request", + "message", exception.getMessage() != null ? exception.getMessage() : "Requisição inválida" + )) + .build(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/IllegalStateExceptionMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/IllegalStateExceptionMapper.java new file mode 100644 index 0000000..44b60e5 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/IllegalStateExceptionMapper.java @@ -0,0 +1,22 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +import java.util.Map; + +@Provider +public class IllegalStateExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(IllegalStateException exception) { + return Response.status(Response.Status.CONFLICT) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of( + "error", "Conflict", + "message", exception.getMessage() != null ? exception.getMessage() : "Conflito de estado na operação" + )) + .build(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/SecurityExceptionMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/SecurityExceptionMapper.java new file mode 100644 index 0000000..e5ce28a --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/SecurityExceptionMapper.java @@ -0,0 +1,22 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +import java.util.Map; + +@Provider +public class SecurityExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(SecurityException exception) { + return Response.status(Response.Status.FORBIDDEN) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of( + "error", "Forbidden", + "message", exception.getMessage() != null ? exception.getMessage() : "Acesso negado" + )) + .build(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/TaskNotFoundExceptionMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/TaskNotFoundExceptionMapper.java new file mode 100644 index 0000000..f0c3913 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/TaskNotFoundExceptionMapper.java @@ -0,0 +1,23 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import net.beetechgroup.beetask.usecase.exceptions.TaskNotFoundException; + +import java.util.Map; + +@Provider +public class TaskNotFoundExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(TaskNotFoundException exception) { + return Response.status(Response.Status.NOT_FOUND) + .type(MediaType.APPLICATION_JSON) + .entity(Map.of( + "error", "Not Found", + "message", exception.getMessage() != null ? exception.getMessage() : "Tarefa não encontrada" + )) + .build(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryController.java index 0e317d1..4d61701 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryController.java @@ -45,10 +45,11 @@ public class CategoryController { @Operation(summary = "List categories", description = "Lists all categories for the given organization") @APIResponse(responseCode = "200", description = "Categories listed successfully") public List listCategories(@PathParam("orgId") Long orgId) { - List categories = listCategoriesUseCase.execute(orgId).stream() + String email = securityIdentity.getPrincipal().getName(); + List categories = listCategoriesUseCase.execute(email, orgId).stream() .map(CategoryControllerMapper::toResponse) .toList(); - LOGGER.infof("Listed %d categories for organization %d", categories.size(), orgId); + LOGGER.infof("Listed %d categories for organization %d by user %s", categories.size(), orgId, email); return categories; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/dashboard/DashboardController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/dashboard/DashboardController.java index 99785be..bb50169 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/dashboard/DashboardController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/dashboard/DashboardController.java @@ -31,7 +31,8 @@ public class DashboardController { @GET public DashboardOutput getDashboard( @QueryParam("startDate") String startDateStr, - @QueryParam("endDate") String endDateStr) { + @QueryParam("endDate") String endDateStr, + @QueryParam("organizationId") Long organizationId) { String email = securityIdentity.getPrincipal().getName(); @@ -47,8 +48,8 @@ public class DashboardController { end = end.with(java.time.LocalTime.MAX); } - DashboardInput input = new DashboardInput(email, start, end); - LOGGER.infof("Dashboard requested by user %s for period %s to %s", email, start, end); + DashboardInput input = new DashboardInput(email, start, end, organizationId); + LOGGER.infof("Dashboard requested by user %s for period %s to %s with orgId %s", email, start, end, organizationId); return dashboardUseCase.execute(input); } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/organization/OrganizationController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/organization/OrganizationController.java index 85b5522..5b941f9 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/organization/OrganizationController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/organization/OrganizationController.java @@ -52,6 +52,12 @@ public class OrganizationController { @Inject ListOrganizationMembersUseCase listOrganizationMembersUseCase; + @Inject + net.beetechgroup.beetask.usecase.organization.members.RemoveOrganizationMemberUseCase removeOrganizationMemberUseCase; + + @Inject + net.beetechgroup.beetask.usecase.organization.members.LeaveOrganizationUseCase leaveOrganizationUseCase; + @Inject SecurityIdentity securityIdentity; @@ -142,4 +148,26 @@ public class OrganizationController { LOGGER.infof("User %s listed %d organization join requests", userEmail, requests.size()); return requests; } + + @jakarta.ws.rs.DELETE + @Path("/{id}/members/{userId}") + @Authenticated + @Operation(summary = "Remove organization member", description = "Removes a member from the organization (Admin/Owner only)") + public jakarta.ws.rs.core.Response removeMember(@PathParam("id") Long organizationId, @PathParam("userId") Long userId) { + String requesterEmail = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Admin %s requested removal of member %d from organization %d", requesterEmail, userId, organizationId); + removeOrganizationMemberUseCase.execute(requesterEmail, organizationId, userId); + return jakarta.ws.rs.core.Response.noContent().build(); + } + + @POST + @Path("/{id}/leave") + @Authenticated + @Operation(summary = "Leave organization", description = "Allows the current user to leave an organization") + public jakarta.ws.rs.core.Response leaveOrganization(@PathParam("id") Long organizationId) { + String userEmail = securityIdentity.getPrincipal().getName(); + LOGGER.infof("User %s requested to leave organization %d", userEmail, organizationId); + leaveOrganizationUseCase.execute(userEmail, organizationId); + return jakarta.ws.rs.core.Response.noContent().build(); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/project/ProjectController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/project/ProjectController.java index e6bb849..a226ba5 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/project/ProjectController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/project/ProjectController.java @@ -1,6 +1,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.project; import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; import jakarta.inject.Inject; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; @@ -19,6 +20,7 @@ import java.util.List; @Path("/organizations/{orgId}/projects") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) +@Authenticated public class ProjectController { private static final Logger LOGGER = Logger.getLogger(ProjectController.class); @@ -28,25 +30,55 @@ public class ProjectController { @Inject ListProjectsUseCase listProjectsUseCase; + @Inject + net.beetechgroup.beetask.usecase.project.update.UpdateProjectUseCase updateProjectUseCase; + + @Inject + net.beetechgroup.beetask.usecase.project.delete.DeleteProjectUseCase deleteProjectUseCase; + + @Inject + SecurityIdentity securityIdentity; + @GET - @Authenticated public List listProjects(@PathParam("orgId") Long orgId) { - List projects = listProjectsUseCase.execute(orgId).stream() + String email = securityIdentity.getPrincipal().getName(); + List projects = listProjectsUseCase.execute(email, orgId).stream() .map(p -> new ProjectResponse(p.getId(), p.getName(), p.getColor(), p.getIcon())) .toList(); - LOGGER.infof("Listed %d projects for organization %d", projects.size(), orgId); + LOGGER.infof("Listed %d projects for organization %d by user %s", projects.size(), orgId, email); return projects; } @POST - @Authenticated public ProjectResponse createProject(@PathParam("orgId") Long orgId, CreateProjectRequest request) { - LOGGER.infof("Project creation requested for organization %d with name '%s'", orgId, request.name()); - Project project = createProjectUseCase.execute(orgId, request.name(), request.color(), request.icon()); - LOGGER.infof("Project %d created successfully for organization %d", project.getId(), orgId); + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Project creation requested by %s for organization %d with name '%s'", email, orgId, request.name()); + Project project = createProjectUseCase.execute(email, orgId, request.name(), request.color(), request.icon()); + LOGGER.infof("Project %d created successfully for organization %d by user %s", project.getId(), orgId, email); return new ProjectResponse(project.getId(), project.getName(), project.getColor(), project.getIcon()); } + @jakarta.ws.rs.PUT + @Path("/{id}") + public ProjectResponse updateProject(@PathParam("orgId") Long orgId, + @PathParam("id") Long id, + CreateProjectRequest request) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Project update requested by %s for project %d in org %d", email, id, orgId); + Project project = updateProjectUseCase.execute(email, orgId, id, request.name(), request.color(), request.icon()); + return new ProjectResponse(project.getId(), project.getName(), project.getColor(), project.getIcon()); + } + + @jakarta.ws.rs.DELETE + @Path("/{id}") + public jakarta.ws.rs.core.Response deleteProject(@PathParam("orgId") Long orgId, + @PathParam("id") Long id) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Project deletion requested by %s for project %d in org %d", email, id, orgId); + deleteProjectUseCase.execute(email, orgId, id); + return jakarta.ws.rs.core.Response.noContent().build(); + } + // TODO must be a independent file public record CreateProjectRequest(String name, String color, String icon) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java index 8eb9344..78e743e 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java @@ -13,6 +13,7 @@ public record CreateTaskResponse( ProjectResponse project, CategoryResponse category, UserResponse user, + UserResponse createdBy, LocalDateTime finishedAt, List history, String externalUrl) { diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java index 9fe7046..23e109f 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java @@ -3,6 +3,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task; import jakarta.inject.Inject; import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.PATCH; import jakarta.ws.rs.POST; @@ -11,6 +12,8 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; import net.beetechgroup.beetask.usecase.task.start.StartTaskInput; @@ -25,6 +28,7 @@ import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase; +import net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase; import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase; import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksUseCase; import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase; @@ -36,6 +40,7 @@ import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase; @Path("/tasks") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) +@Authenticated @Tag(name = "Tasks", description = "Task management operations") public class TaskController { private static final Logger LOGGER = Logger.getLogger(TaskController.class); @@ -64,6 +69,9 @@ public class TaskController { @Inject GetTaskUseCase getTaskUseCase; + @Inject + DeleteTaskUseCase deleteTaskUseCase; + @Inject SecurityIdentity securityIdentity; @@ -96,8 +104,9 @@ public class TaskController { @Operation(summary = "Get a task", description = "Gets a specific task by ID") @APIResponse(responseCode = "200", description = "Task retrieved successfully") public CreateTaskResponse getTask(@PathParam("id") Long id) { - LOGGER.infof("Task details requested for task %d", id); - return TaskControllerMapper.toCreateTaskResponse(getTaskUseCase.execute(id)); + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Task details requested for task %d by %s", id, email); + return TaskControllerMapper.toCreateTaskResponse(getTaskUseCase.execute(id, email)); } @PUT @@ -129,12 +138,24 @@ public class TaskController { @Operation(summary = "Update task status", description = "Updates the status of a task") @APIResponse(responseCode = "200", description = "Task status updated successfully") public CreateTaskResponse updateTaskStatus(@PathParam("id") Long id, TaskStatus status) { - LOGGER.infof("Status update requested for task %d to %s", id, status); - CreateTaskOutput output = updateTaskStatusUseCase.execute(id, status); + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Status update requested for task %d to %s by %s", id, status, email); + CreateTaskOutput output = updateTaskStatusUseCase.execute(id, status, email); LOGGER.infof("Task %d status updated successfully to %s", id, status); return TaskControllerMapper.toCreateTaskResponse(output); } + @DELETE + @Path("/{id}") + @Operation(summary = "Delete a task", description = "Deletes a task if requester is admin of the organization or the owner/creator") + @APIResponse(responseCode = "204", description = "Task deleted successfully") + public Response deleteTask(@PathParam("id") Long id) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("User %s requested deletion of task %d", email, id); + deleteTaskUseCase.execute(id, email); + return Response.noContent().build(); + } + @GET @Operation(summary = "List all tasks", description = "Lists all tasks in the system") @APIResponse(responseCode = "200", description = "Tasks listed successfully") diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java index 1f67684..e483930 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java @@ -68,6 +68,13 @@ public class TaskControllerMapper { output.user().email(), output.user().photo()) : null, + Objects.nonNull(output.createdBy()) + ? new CreateTaskResponse.UserResponse( + output.createdBy().id(), + output.createdBy().name(), + output.createdBy().email(), + output.createdBy().photo()) + : null, output.finishedAt(), output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse( taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt(), taskHistoryItemOutput.elapsedSeconds())).toList(), diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/PublicUserController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/PublicUserController.java new file mode 100644 index 0000000..34faf69 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/PublicUserController.java @@ -0,0 +1,28 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.user; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import java.util.List; + +@Path("/public/users") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Users") +public class PublicUserController { + + @Inject + UserController userController; + + @GET + @Operation(summary = "List all users publicly") + public List listUsers() { + return userController.listUsers(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserController.java new file mode 100644 index 0000000..6d38170 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserController.java @@ -0,0 +1,36 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.user; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.logging.Logger; + +import java.util.List; + +@Path("/users") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Users", description = "User management operations") +public class UserController { + private static final Logger LOGGER = Logger.getLogger(UserController.class); + + @Inject + ListAllUsersUseCase listAllUsersUseCase; + + @GET + @Operation(summary = "List all users", description = "Lists all users with name and email, without pagination or authentication required") + public List listUsers() { + LOGGER.info("Public list of all users requested"); + return listAllUsersUseCase.execute().stream() + .map(u -> new UserResponse(u.getName(), u.getEmail())) + .toList(); + } + + public record UserResponse(String name, String email) {} +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/login/LoginUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/login/LoginUseCase.java index d73c6ef..6a134ef 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/login/LoginUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/login/LoginUseCase.java @@ -36,8 +36,25 @@ public class LoginUseCase { User user = userOptional.get(); - // Simple password check for now (should use Bcrypt) - if (!user.getPassword().equals(input.password())) { + boolean passwordMatches = false; + String storedPassword = user.getPassword(); + if (storedPassword != null) { + if (storedPassword.startsWith("$2a$") || storedPassword.startsWith("$2b$") || storedPassword.startsWith("$2y$")) { + try { + passwordMatches = org.mindrot.jbcrypt.BCrypt.checkpw(input.password(), storedPassword); + } catch (Exception e) { + passwordMatches = false; + } + } else { + passwordMatches = storedPassword.equals(input.password()); + if (passwordMatches) { + user.setPassword(org.mindrot.jbcrypt.BCrypt.hashpw(input.password(), org.mindrot.jbcrypt.BCrypt.gensalt(10))); + userRepository.save(user); + } + } + } + + if (!passwordMatches) { LOGGER.warnf("Login failed because password did not match for email %s", input.email()); throw new RuntimeException("Invalid credentials"); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/category/list/ListCategoriesUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/list/ListCategoriesUseCase.java index e64a42c..dbda42e 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/category/list/ListCategoriesUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/list/ListCategoriesUseCase.java @@ -4,22 +4,27 @@ import java.util.List; import net.beetechgroup.beetask.usecase.category.CategoryMapper; import net.beetechgroup.beetask.usecase.category.CategoryOutput; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import org.jboss.logging.Logger; public class ListCategoriesUseCase { private static final Logger LOGGER = Logger.getLogger(ListCategoriesUseCase.class); private final CategoryRepository categoryRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; - public ListCategoriesUseCase(CategoryRepository categoryRepository) { + public ListCategoriesUseCase(CategoryRepository categoryRepository, AuthorizeOrganizationMemberUseCase authorizer) { this.categoryRepository = categoryRepository; + this.authorizer = authorizer; } - public List execute(Long organizationId) { + public List execute(String userEmail, Long organizationId) { + authorizer.execute(userEmail, organizationId); List categories = categoryRepository.findByOrganizationId(organizationId).stream() .map(CategoryMapper::toCategoryOutput) .toList(); - LOGGER.infof("Loaded %d categories for organization %d", categories.size(), organizationId); + LOGGER.infof("Loaded %d categories for organization %d by user %s", categories.size(), organizationId, userEmail); return categories; } } + diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardInput.java index a1f74ad..ccb2200 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardInput.java @@ -5,5 +5,10 @@ import java.time.LocalDateTime; public record DashboardInput( String userEmail, LocalDateTime startDate, - LocalDateTime endDate -) {} + LocalDateTime endDate, + Long organizationId +) { + public DashboardInput(String userEmail, LocalDateTime startDate, LocalDateTime endDate) { + this(userEmail, startDate, endDate, null); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardUseCase.java index a253d93..c6b07b3 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardUseCase.java @@ -33,17 +33,24 @@ public class DashboardUseCase { LOGGER.infof("Calculating dashboard for user %s in period %s to %s", input.userEmail(), input.startDate(), input.endDate()); List workedTasks = taskRepository.findTasksWorkedByUserInPeriod( - input.userEmail(), input.startDate(), input.endDate() + input.userEmail(), input.startDate(), input.endDate(), input.organizationId() ); List finishedTasks = taskRepository.findTasksFinishedByUserInPeriod( input.userEmail(), input.startDate(), input.endDate() ); + if (input.organizationId() != null) { + finishedTasks = finishedTasks.stream().filter(t -> + (t.getOrganization() != null && input.organizationId().equals(t.getOrganization().getId())) || + (t.getProject() != null && t.getProject().getOrganization() != null && input.organizationId().equals(t.getProject().getOrganization().getId())) || + (t.getCategory() != null && t.getCategory().getOrganization() != null && input.organizationId().equals(t.getCategory().getOrganization().getId())) + ).toList(); + } LocalDateTime startOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MIN); LocalDateTime endOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MAX); List yesterdayTasks = taskRepository.findTasksWorkedByUserInPeriod( - input.userEmail(), startOfYesterday, endOfYesterday + input.userEmail(), startOfYesterday, endOfYesterday, input.organizationId() ); long totalMinutes = 0; diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/auth/AuthorizeOrganizationMemberUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/auth/AuthorizeOrganizationMemberUseCase.java new file mode 100644 index 0000000..0dd2d07 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/auth/AuthorizeOrganizationMemberUseCase.java @@ -0,0 +1,41 @@ +package net.beetechgroup.beetask.usecase.organization.auth; + +import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.entities.organization.UserOrganization; +import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; +import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import org.jboss.logging.Logger; + +import java.util.Optional; + +public class AuthorizeOrganizationMemberUseCase { + private static final Logger LOGGER = Logger.getLogger(AuthorizeOrganizationMemberUseCase.class); + private final UserRepository userRepository; + private final UserOrganizationRepository userOrganizationRepository; + + public AuthorizeOrganizationMemberUseCase(UserRepository userRepository, + UserOrganizationRepository userOrganizationRepository) { + this.userRepository = userRepository; + this.userOrganizationRepository = userOrganizationRepository; + } + + public User execute(String userEmail, Long organizationId) { + if (userEmail == null || userEmail.isBlank()) { + throw new SecurityException("Usuário não autenticado."); + } + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new SecurityException("Usuário não encontrado: " + userEmail)); + + if (organizationId == null) { + return user; + } + + Optional association = userOrganizationRepository.findByUserAndOrganization(user.getId(), organizationId); + if (association.isEmpty() || association.get().getStatus() != UserOrganizationStatus.ACTIVE) { + LOGGER.warnf("User %s is not an active member of organization %d", userEmail, organizationId); + throw new SecurityException("Você não tem permissão para acessar recursos desta organização."); + } + return user; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/join/RequestJoinOrganizationUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/join/RequestJoinOrganizationUseCase.java index 94aa749..5f9785a 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/join/RequestJoinOrganizationUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/join/RequestJoinOrganizationUseCase.java @@ -47,10 +47,9 @@ public class RequestJoinOrganizationUseCase { LOGGER.warnf("Join request ignored because user %s already has a pending request for organization %d", userEmail, organizationId); throw new RuntimeException("A join request is already pending for this organization"); } - // If REJECTED, we allow a new request (or we could just update the existing one to PENDING) } - UserOrganization request = new UserOrganization(); + UserOrganization request = existing.orElseGet(UserOrganization::new); request.setUser(user); request.setOrganization(organization); request.setRole(UserOrganizationRole.MEMBER); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/members/LeaveOrganizationUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/members/LeaveOrganizationUseCase.java new file mode 100644 index 0000000..b8bf829 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/members/LeaveOrganizationUseCase.java @@ -0,0 +1,72 @@ +package net.beetechgroup.beetask.usecase.organization.members; + +import jakarta.persistence.EntityManager; +import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.entities.organization.UserOrganization; +import net.beetechgroup.beetask.entities.organization.UserOrganizationRole; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import org.jboss.logging.Logger; + +import java.time.LocalDateTime; +import java.util.Optional; + +public class LeaveOrganizationUseCase { + private static final Logger LOGGER = Logger.getLogger(LeaveOrganizationUseCase.class); + + private final UserOrganizationRepository userOrganizationRepository; + private final UserRepository userRepository; + private final AuthorizeOrganizationMemberUseCase memberAuthorizer; + private final EntityManager entityManager; + + public LeaveOrganizationUseCase(UserOrganizationRepository userOrganizationRepository, + UserRepository userRepository, + AuthorizeOrganizationMemberUseCase memberAuthorizer, + EntityManager entityManager) { + this.userOrganizationRepository = userOrganizationRepository; + this.userRepository = userRepository; + this.memberAuthorizer = memberAuthorizer; + this.entityManager = entityManager; + } + + @jakarta.transaction.Transactional + public void execute(String userEmail, Long organizationId) { + LOGGER.infof("User %s requested to leave organization %d", userEmail, organizationId); + memberAuthorizer.execute(userEmail, organizationId); + + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("Usuário não encontrado: " + userEmail)); + + Optional membership = userOrganizationRepository.findByUserAndOrganization(user.getId(), organizationId); + if (membership.isEmpty()) { + throw new IllegalArgumentException("Membro não encontrado nesta organização."); + } + + if (membership.get().getRole() == UserOrganizationRole.OWNER) { + throw new IllegalArgumentException("O proprietário não pode sair da organização. Transfira a posse antes de sair."); + } + + userOrganizationRepository.deleteByUserAndOrganization(user.getId(), organizationId); + + // Unassign user from tasks in this organization + entityManager.createQuery( + "UPDATE TaskEntity t SET t.user = NULL " + + "WHERE t.user.id = :userId AND (t.organization.id = :orgId OR t.project.organization.id = :orgId OR t.category.organization.id = :orgId)") + .setParameter("userId", user.getId()) + .setParameter("orgId", organizationId) + .executeUpdate(); + + // Close any running timer for this user in this organization + entityManager.createQuery( + "UPDATE TaskHistoryItemEntity h SET h.endAt = :now " + + "WHERE h.endAt IS NULL AND (h.user.id = :userId OR (h.user IS NULL AND h.task.user.id = :userId)) AND " + + "h.task.id IN (SELECT t.id FROM TaskEntity t WHERE t.organization.id = :orgId OR t.project.organization.id = :orgId OR t.category.organization.id = :orgId)") + .setParameter("now", LocalDateTime.now()) + .setParameter("userId", user.getId()) + .setParameter("orgId", organizationId) + .executeUpdate(); + + LOGGER.infof("User %s successfully left organization %d", userEmail, organizationId); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/members/RemoveOrganizationMemberUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/members/RemoveOrganizationMemberUseCase.java new file mode 100644 index 0000000..4a23137 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/members/RemoveOrganizationMemberUseCase.java @@ -0,0 +1,69 @@ +package net.beetechgroup.beetask.usecase.organization.members; + +import jakarta.persistence.EntityManager; +import net.beetechgroup.beetask.entities.organization.UserOrganization; +import net.beetechgroup.beetask.entities.organization.UserOrganizationRole; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase; +import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import org.jboss.logging.Logger; + +import java.time.LocalDateTime; +import java.util.Optional; + +public class RemoveOrganizationMemberUseCase { + private static final Logger LOGGER = Logger.getLogger(RemoveOrganizationMemberUseCase.class); + + private final UserOrganizationRepository userOrganizationRepository; + private final UserRepository userRepository; + private final AuthorizeOrganizationAdminUseCase adminAuthorizer; + private final EntityManager entityManager; + + public RemoveOrganizationMemberUseCase(UserOrganizationRepository userOrganizationRepository, + UserRepository userRepository, + AuthorizeOrganizationAdminUseCase adminAuthorizer, + EntityManager entityManager) { + this.userOrganizationRepository = userOrganizationRepository; + this.userRepository = userRepository; + this.adminAuthorizer = adminAuthorizer; + this.entityManager = entityManager; + } + + @jakarta.transaction.Transactional + public void execute(String requesterEmail, Long organizationId, Long targetUserId) { + LOGGER.infof("Admin %s requested removal of member %d from organization %d", requesterEmail, targetUserId, organizationId); + adminAuthorizer.execute(requesterEmail, organizationId); + + Optional targetMembership = userOrganizationRepository.findByUserAndOrganization(targetUserId, organizationId); + if (targetMembership.isEmpty()) { + throw new IllegalArgumentException("Membro não encontrado nesta organização."); + } + + if (targetMembership.get().getRole() == UserOrganizationRole.OWNER) { + throw new IllegalArgumentException("O proprietário da organização não pode ser removido."); + } + + // Soft delete the membership + userOrganizationRepository.deleteByUserAndOrganization(targetUserId, organizationId); + + // Unassign member from tasks in this organization + entityManager.createQuery( + "UPDATE TaskEntity t SET t.user = NULL " + + "WHERE t.user.id = :userId AND (t.organization.id = :orgId OR t.project.organization.id = :orgId OR t.category.organization.id = :orgId)") + .setParameter("userId", targetUserId) + .setParameter("orgId", organizationId) + .executeUpdate(); + + // Close any running timer for this member in this organization + entityManager.createQuery( + "UPDATE TaskHistoryItemEntity h SET h.endAt = :now " + + "WHERE h.endAt IS NULL AND (h.user.id = :userId OR (h.user IS NULL AND h.task.user.id = :userId)) AND " + + "h.task.id IN (SELECT t.id FROM TaskEntity t WHERE t.organization.id = :orgId OR t.project.organization.id = :orgId OR t.category.organization.id = :orgId)") + .setParameter("now", LocalDateTime.now()) + .setParameter("userId", targetUserId) + .setParameter("orgId", organizationId) + .executeUpdate(); + + LOGGER.infof("Member %d removed successfully from organization %d", targetUserId, organizationId); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java index de6e3b9..d82116a 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java @@ -48,10 +48,10 @@ public class OrgDashboardUseCase { .toList(); List workedTasks = taskRepository.findTasksWorkedByUserEmailsInPeriod( - memberEmails, input.startDate(), input.endDate() + input.organizationId(), memberEmails, input.startDate(), input.endDate() ); List finishedTasks = taskRepository.findTasksFinishedByUserEmailsInPeriod( - memberEmails, input.startDate(), input.endDate() + input.organizationId(), memberEmails, input.startDate(), input.endDate() ); Map projectStatsMap = new HashMap<>(); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java index 6c91ba6..f9cbdf2 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java @@ -54,8 +54,8 @@ public class MemberDetailUseCase { long daySpan = ChronoUnit.DAYS.between(start.toLocalDate(), end.toLocalDate()); boolean groupByDay = daySpan <= 30; - List workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.memberId(), start, end); - List finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end); + List workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.organizationId(), input.memberId(), start, end); + List finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.organizationId(), input.memberId(), start, end); Map projectStatsMap = new HashMap<>(); Map categoryStatsMap = new HashMap<>(); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/project/create/CreateProjectUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/project/create/CreateProjectUseCase.java index 37bf0e3..aefa724 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/project/create/CreateProjectUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/project/create/CreateProjectUseCase.java @@ -2,6 +2,7 @@ package net.beetechgroup.beetask.usecase.project.create; import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.organization.Organization; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import org.jboss.logging.Logger; @@ -10,22 +11,36 @@ public class CreateProjectUseCase { private static final Logger LOGGER = Logger.getLogger(CreateProjectUseCase.class); private final ProjectRepository projectRepository; private final OrganizationRepository organizationRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; - public CreateProjectUseCase(ProjectRepository projectRepository, OrganizationRepository organizationRepository) { + public CreateProjectUseCase(ProjectRepository projectRepository, + OrganizationRepository organizationRepository, + AuthorizeOrganizationMemberUseCase authorizer) { this.projectRepository = projectRepository; this.organizationRepository = organizationRepository; + this.authorizer = authorizer; } - public Project execute(Long organizationId, String projectName, String color, String icon) { + public Project execute(String userEmail, Long organizationId, String projectName, String color, String icon) { + authorizer.execute(userEmail, organizationId); + Organization organization = organizationRepository.findOrganizationById(organizationId) .orElseThrow(() -> { LOGGER.warnf("Project creation failed because organization %d was not found", organizationId); return new RuntimeException("Organization not found"); }); - LOGGER.infof("Creating project '%s' in organization %d", projectName, organizationId); + if (projectName == null || projectName.isBlank()) { + throw new IllegalArgumentException("Nome do projeto não pode ser vazio."); + } + + if (projectRepository.findByNameAndOrganizationId(projectName, organizationId).isPresent()) { + throw new IllegalArgumentException("Já existe um projeto com este nome nesta organização."); + } + + LOGGER.infof("Creating project '%s' in organization %d by user %s", projectName, organizationId, userEmail); Project project = new Project(); - project.setName(projectName); + project.setName(projectName.trim()); project.setColor(color); project.setIcon(icon); project.setOrganization(organization); @@ -35,3 +50,4 @@ public class CreateProjectUseCase { return savedProject; } } + diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/project/delete/DeleteProjectUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/project/delete/DeleteProjectUseCase.java new file mode 100644 index 0000000..60f30e7 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/project/delete/DeleteProjectUseCase.java @@ -0,0 +1,32 @@ +package net.beetechgroup.beetask.usecase.project.delete; + +import net.beetechgroup.beetask.entities.Project; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase; +import net.beetechgroup.beetask.usecase.repository.ProjectRepository; +import org.jboss.logging.Logger; + +public class DeleteProjectUseCase { + private static final Logger LOGGER = Logger.getLogger(DeleteProjectUseCase.class); + + private final ProjectRepository projectRepository; + private final AuthorizeOrganizationAdminUseCase authorizer; + + public DeleteProjectUseCase(ProjectRepository projectRepository, AuthorizeOrganizationAdminUseCase authorizer) { + this.projectRepository = projectRepository; + this.authorizer = authorizer; + } + + public void execute(String userEmail, Long organizationId, Long projectId) { + authorizer.execute(userEmail, organizationId); + + Project project = projectRepository.findProjectById(projectId) + .orElseThrow(() -> new IllegalArgumentException("Projeto não encontrado com ID: " + projectId)); + + if (project.getOrganization() == null || !project.getOrganization().getId().equals(organizationId)) { + throw new IllegalArgumentException("Projeto não pertence à organização informada."); + } + + projectRepository.deleteProject(projectId); + LOGGER.infof("Project %d soft deleted from org %d by %s", projectId, organizationId, userEmail); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/project/list/ListProjectsUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/project/list/ListProjectsUseCase.java index b7e4924..40257c2 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/project/list/ListProjectsUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/project/list/ListProjectsUseCase.java @@ -1,6 +1,7 @@ package net.beetechgroup.beetask.usecase.project.list; import net.beetechgroup.beetask.entities.Project; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import java.util.List; @@ -9,14 +10,18 @@ import org.jboss.logging.Logger; public class ListProjectsUseCase { private static final Logger LOGGER = Logger.getLogger(ListProjectsUseCase.class); private final ProjectRepository projectRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; - public ListProjectsUseCase(ProjectRepository projectRepository) { + public ListProjectsUseCase(ProjectRepository projectRepository, AuthorizeOrganizationMemberUseCase authorizer) { this.projectRepository = projectRepository; + this.authorizer = authorizer; } - public List execute(Long organizationId) { + public List execute(String userEmail, Long organizationId) { + authorizer.execute(userEmail, organizationId); List projects = projectRepository.findByOrganizationId(organizationId); LOGGER.infof("Loaded %d projects for organization %d", projects.size(), organizationId); return projects; } } + diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/project/update/UpdateProjectUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/project/update/UpdateProjectUseCase.java new file mode 100644 index 0000000..473acd5 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/project/update/UpdateProjectUseCase.java @@ -0,0 +1,48 @@ +package net.beetechgroup.beetask.usecase.project.update; + +import net.beetechgroup.beetask.entities.Project; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.ProjectRepository; +import org.jboss.logging.Logger; + +import java.util.Optional; + +public class UpdateProjectUseCase { + private static final Logger LOGGER = Logger.getLogger(UpdateProjectUseCase.class); + + private final ProjectRepository projectRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; + + public UpdateProjectUseCase(ProjectRepository projectRepository, AuthorizeOrganizationMemberUseCase authorizer) { + this.projectRepository = projectRepository; + this.authorizer = authorizer; + } + + public Project execute(String userEmail, Long organizationId, Long projectId, String name, String color, String icon) { + authorizer.execute(userEmail, organizationId); + + Project project = projectRepository.findProjectById(projectId) + .orElseThrow(() -> new IllegalArgumentException("Projeto não encontrado com ID: " + projectId)); + + if (project.getOrganization() == null || !project.getOrganization().getId().equals(organizationId)) { + throw new IllegalArgumentException("Projeto não pertence à organização informada."); + } + + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Nome do projeto não pode ser vazio."); + } + + Optional duplicate = projectRepository.findByNameAndOrganizationId(name, organizationId); + if (duplicate.isPresent() && !duplicate.get().getId().equals(projectId)) { + throw new IllegalArgumentException("Já existe um projeto com este nome nesta organização."); + } + + project.setName(name.trim()); + if (color != null) project.setColor(color); + if (icon != null) project.setIcon(icon); + + Project updated = projectRepository.saveProject(project); + LOGGER.infof("Project %d updated in org %d by %s", projectId, organizationId, userEmail); + return updated; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/ProjectRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/ProjectRepository.java index 115f59b..ecc4c6f 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/ProjectRepository.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/ProjectRepository.java @@ -8,4 +8,6 @@ public interface ProjectRepository { Project saveProject(Project project); Optional findProjectById(Long id); List findByOrganizationId(Long organizationId); + Optional findByNameAndOrganizationId(String name, Long organizationId); + void deleteProject(Long id); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java index 6bb36b2..8848d31 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java @@ -12,7 +12,9 @@ public interface TaskRepository { List findAllTasks(); List findAccessibleTasksFiltered(Long organizationId, String text, List projectIds, List statuses, List categoryIds, List userIds); + List findRunningTasksByUserEmail(String email); List findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); + List findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId); List findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksByUser(String email); List findTasksByUserFiltered(String email, String text, List projectIds, @@ -20,9 +22,14 @@ public interface TaskRepository { List findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); + List findTasksWorkedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end); List findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); + List findTasksFinishedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end); List findTasksWorkedByUserEmailsInPeriod(List emails, LocalDateTime start, LocalDateTime end); + List findTasksWorkedByUserEmailsInPeriod(Long orgId, List emails, LocalDateTime start, LocalDateTime end); List findTasksFinishedByUserEmailsInPeriod(List emails, LocalDateTime start, LocalDateTime end); + List findTasksFinishedByUserEmailsInPeriod(Long orgId, List emails, LocalDateTime start, LocalDateTime end); long countByStatus(TaskStatus status); long sumAllCompletedTrackedMinutes(); + void deleteTask(Long id); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserOrganizationRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserOrganizationRepository.java index 2c17ac0..bcf25e2 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserOrganizationRepository.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserOrganizationRepository.java @@ -11,4 +11,5 @@ public interface UserOrganizationRepository { List findByOrganizationIdAndStatus(Long organizationId, UserOrganizationStatus status); Optional findByUserAndOrganization(Long userId, Long organizationId); void save(UserOrganization userOrganization); + void deleteByUserAndOrganization(Long userId, Long organizationId); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserRepository.java index 8fe49be..bf2f024 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserRepository.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserRepository.java @@ -11,4 +11,5 @@ public interface UserRepository { Optional findUserById(Long id); List findUserOrganizations(Long userId); long countAll(); + List findAllUsers(); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java index 8a13ee1..1a24de6 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java @@ -28,6 +28,13 @@ public class CreateTaskMapper { task.getUser().getEmail(), getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto())) : null, + Objects.nonNull(task.getCreatedBy()) + ? new CreateTaskOutput.UserOutput( + task.getCreatedBy().getId(), + task.getCreatedBy().getName(), + task.getCreatedBy().getEmail(), + getPhotoUrl(task.getCreatedBy().getName(), task.getCreatedBy().getPhoto())) + : null, task.getFinishedAt(), task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList(), task.getExternalUrl()); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java index faaee1d..be21fee 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java @@ -12,6 +12,7 @@ public record CreateTaskOutput( ProjectOutput project, CategoryOutput category, UserOutput user, + UserOutput createdBy, LocalDateTime finishedAt, List history, String externalUrl) { diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java index 98c4ed0..6fb0d3c 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java @@ -8,6 +8,7 @@ import net.beetechgroup.beetask.entities.User; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.TaskHistoryItem; +import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository; @@ -22,15 +23,18 @@ public class CreateTaskUseCase { private final CategoryRepository categoryRepository; private final UserRepository userRepository; private final UserOrganizationRepository userOrganizationRepository; + private final OrganizationRepository organizationRepository; public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, CategoryRepository categoryRepository, UserRepository userRepository, - UserOrganizationRepository userOrganizationRepository) { + UserOrganizationRepository userOrganizationRepository, + OrganizationRepository organizationRepository) { this.taskRepository = taskRepository; this.projectRepository = projectRepository; this.categoryRepository = categoryRepository; this.userRepository = userRepository; this.userOrganizationRepository = userOrganizationRepository; + this.organizationRepository = organizationRepository; } public CreateTaskOutput execute(CreateTaskInput input) { @@ -60,7 +64,17 @@ public class CreateTaskUseCase { } validateRequesterMembership(input.organizationId(), input.userEmail()); + if (Objects.nonNull(input.organizationId())) { + organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization); + } else if (task.getProject() != null && task.getProject().getOrganization() != null) { + task.setOrganization(task.getProject().getOrganization()); + } else if (task.getCategory() != null && task.getCategory().getOrganization() != null) { + task.setOrganization(task.getCategory().getOrganization()); + } + task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId())); + User creator = userRepository.findByEmail(input.userEmail()).orElse(null); + task.setCreatedBy(creator); if (Objects.nonNull(input.history())) { List historyItems = input.history().stream().map(h -> { diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/delete/DeleteTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/delete/DeleteTaskUseCase.java new file mode 100644 index 0000000..ea9cb9b --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/delete/DeleteTaskUseCase.java @@ -0,0 +1,76 @@ +package net.beetechgroup.beetask.usecase.task.delete; + +import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.entities.organization.UserOrganization; +import net.beetechgroup.beetask.entities.organization.UserOrganizationRole; +import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; +import net.beetechgroup.beetask.entities.task.Task; +import net.beetechgroup.beetask.usecase.repository.TaskRepository; +import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import org.jboss.logging.Logger; + +import java.util.Optional; + +public class DeleteTaskUseCase { + private static final Logger LOGGER = Logger.getLogger(DeleteTaskUseCase.class); + + private final TaskRepository taskRepository; + private final UserRepository userRepository; + private final UserOrganizationRepository userOrganizationRepository; + + public DeleteTaskUseCase(TaskRepository taskRepository, + UserRepository userRepository, + UserOrganizationRepository userOrganizationRepository) { + this.taskRepository = taskRepository; + this.userRepository = userRepository; + this.userOrganizationRepository = userOrganizationRepository; + } + + public void execute(Long taskId, String userEmail) { + LOGGER.infof("Delete task %d requested by %s", taskId, userEmail); + + Task task = taskRepository.findTaskById(taskId); + + User requester = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new SecurityException("Usuário não encontrado: " + userEmail)); + + // 1. Check if requester is the task creator/owner + boolean isCreatorOrOwner = false; + if (task.getCreatedBy() != null && task.getCreatedBy().getId() != null && task.getCreatedBy().getId().equals(requester.getId())) { + isCreatorOrOwner = true; + } else if (task.getUser() != null && task.getUser().getId() != null && task.getUser().getId().equals(requester.getId())) { + isCreatorOrOwner = true; + } + + // 2. Check if requester is admin/owner of the task's organization + Long taskOrgId = null; + if (task.getOrganization() != null) { + taskOrgId = task.getOrganization().getId(); + } else if (task.getProject() != null && task.getProject().getOrganization() != null) { + taskOrgId = task.getProject().getOrganization().getId(); + } else if (task.getCategory() != null && task.getCategory().getOrganization() != null) { + taskOrgId = task.getCategory().getOrganization().getId(); + } + + boolean isOrgAdmin = false; + if (taskOrgId != null) { + Optional uo = userOrganizationRepository.findByUserAndOrganization(requester.getId(), taskOrgId); + if (uo.isPresent() && uo.get().getStatus() == UserOrganizationStatus.ACTIVE) { + UserOrganizationRole role = uo.get().getRole(); + if (role == UserOrganizationRole.OWNER || role == UserOrganizationRole.ADMIN) { + isOrgAdmin = true; + } + } + } + + if (!isCreatorOrOwner && !isOrgAdmin) { + LOGGER.warnf("User %s denied deletion of task %d. isCreatorOrOwner=%b, isOrgAdmin=%b, taskOrgId=%d", + userEmail, taskId, isCreatorOrOwner, isOrgAdmin, taskOrgId); + throw new SecurityException("Você não tem permissão para excluir esta tarefa. Apenas o criador ou administradores da organização podem excluí-la."); + } + + taskRepository.deleteTask(taskId); + LOGGER.infof("Task %d deleted successfully by user %s", taskId, userEmail); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/get/GetTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/get/GetTaskUseCase.java index 5bba8f0..ef2f9f2 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/get/GetTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/get/GetTaskUseCase.java @@ -1,5 +1,7 @@ package net.beetechgroup.beetask.usecase.task.get; +import net.beetechgroup.beetask.entities.task.Task; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; @@ -8,14 +10,32 @@ import org.jboss.logging.Logger; public class GetTaskUseCase { private static final Logger LOGGER = Logger.getLogger(GetTaskUseCase.class); private final TaskRepository taskRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; - public GetTaskUseCase(TaskRepository taskRepository) { + public GetTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) { this.taskRepository = taskRepository; + this.authorizer = authorizer; } - public CreateTaskOutput execute(Long id) { - //TODO deve validar se o usuario logado é o dono da task - LOGGER.infof("Fetching task %d", id); - return CreateTaskMapper.toCreateTaskOutput(taskRepository.findTaskById(id)); + public CreateTaskOutput execute(Long id, String userEmail) { + LOGGER.infof("Fetching task %d requested by %s", id, userEmail); + Task task = taskRepository.findTaskById(id); + + Long orgId = null; + if (task.getProject() != null && task.getProject().getOrganization() != null) { + orgId = task.getProject().getOrganization().getId(); + } else if (task.getCategory() != null && task.getCategory().getOrganization() != null) { + orgId = task.getCategory().getOrganization().getId(); + } + + if (orgId != null) { + authorizer.execute(userEmail, orgId); + } else if (task.getUser() != null && !task.getUser().getEmail().equals(userEmail)) { + LOGGER.warnf("User %s denied access to task %d owned by %s", userEmail, id, task.getUser().getEmail()); + throw new SecurityException("Você não tem permissão para acessar esta tarefa."); + } + + return CreateTaskMapper.toCreateTaskOutput(task); } } + diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/start/StartTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/start/StartTaskUseCase.java index ba7e37b..01218c6 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/start/StartTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/start/StartTaskUseCase.java @@ -2,6 +2,7 @@ package net.beetechgroup.beetask.usecase.task.start; import java.util.Objects; import net.beetechgroup.beetask.entities.task.Task; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; @@ -13,25 +14,53 @@ public class StartTaskUseCase { private final TaskRepository taskRepository; private final UserRepository userRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; - public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository) { + public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) { this.taskRepository = taskRepository; this.userRepository = userRepository; + this.authorizer = authorizer; } public CreateTaskOutput execute(StartTaskInput input) { LOGGER.infof("Starting task %d for user %s", input.id(), input.userEmail()); Task task = taskRepository.findTaskById(input.id()); + Long orgId = null; + if (task.getOrganization() != null) { + orgId = task.getOrganization().getId(); + } else if (task.getProject() != null && task.getProject().getOrganization() != null) { + orgId = task.getProject().getOrganization().getId(); + } else if (task.getCategory() != null && task.getCategory().getOrganization() != null) { + orgId = task.getCategory().getOrganization().getId(); + } + + if (orgId != null) { + authorizer.execute(input.userEmail(), orgId); + } + if (Objects.nonNull(task.getUser()) && !task.getUser().getEmail().equals(input.userEmail())) { LOGGER.warnf("User %s attempted to start task %d owned by %s", input.userEmail(), input.id(), task.getUser().getEmail()); throw new SecurityException("Você não tem permissão para iniciar esta tarefa."); } - task.start(); + net.beetechgroup.beetask.entities.User currentUser = userRepository.findByEmail(input.userEmail()).orElse(null); + + // Auto-stop any existing running timers for this user across all tasks (Toggl style) + java.util.List runningTasks = taskRepository.findRunningTasksByUserEmail(input.userEmail()); + for (Task running : runningTasks) { + if (!running.getId().equals(task.getId()) && running.isRunning()) { + LOGGER.infof("Auto-stopping previously running task %d for user %s", running.getId(), input.userEmail()); + running.stop(); + taskRepository.saveTask(running); + } + } + + task.start(currentUser); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); LOGGER.infof("Task %d started successfully", input.id()); return output; } } + diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskUseCase.java index 8f137ea..06d671e 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskUseCase.java @@ -1,6 +1,7 @@ package net.beetechgroup.beetask.usecase.task.stop; import net.beetechgroup.beetask.entities.task.Task; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; @@ -10,15 +11,28 @@ public class StopTaskUseCase { private static final Logger LOGGER = Logger.getLogger(StopTaskUseCase.class); private final TaskRepository taskRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; - public StopTaskUseCase(TaskRepository taskRepository) { + public StopTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) { this.taskRepository = taskRepository; + this.authorizer = authorizer; } public CreateTaskOutput execute(StopTaskInput input) { LOGGER.infof("Stopping task %d for user %s", input.id(), input.userEmail()); Task task = taskRepository.findTaskById(input.id()); + Long orgId = null; + if (task.getProject() != null && task.getProject().getOrganization() != null) { + orgId = task.getProject().getOrganization().getId(); + } else if (task.getCategory() != null && task.getCategory().getOrganization() != null) { + orgId = task.getCategory().getOrganization().getId(); + } + + if (orgId != null) { + authorizer.execute(input.userEmail(), orgId); + } + if (task.getUser() != null && !task.getUser().getEmail().equals(input.userEmail())) { LOGGER.warnf("User %s attempted to stop task %d owned by %s", input.userEmail(), input.id(), task.getUser().getEmail()); @@ -31,3 +45,4 @@ public class StopTaskUseCase { return output; } } + diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskStatusUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskStatusUseCase.java index 5155bbe..0b22009 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskStatusUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskStatusUseCase.java @@ -1,9 +1,8 @@ package net.beetechgroup.beetask.usecase.task.update; - - import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.TaskStatus; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; @@ -13,15 +12,31 @@ public class UpdateTaskStatusUseCase { private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class); private final TaskRepository taskRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; - public UpdateTaskStatusUseCase(TaskRepository taskRepository) { + public UpdateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) { this.taskRepository = taskRepository; + this.authorizer = authorizer; } - public CreateTaskOutput execute(Long id, TaskStatus newStatus) { - LOGGER.infof("Updating status for task %d to %s", id, newStatus); + public CreateTaskOutput execute(Long id, TaskStatus newStatus, String userEmail) { + LOGGER.infof("Updating status for task %d to %s by user %s", id, newStatus, userEmail); Task task = taskRepository.findTaskById(id); + Long orgId = null; + if (task.getProject() != null && task.getProject().getOrganization() != null) { + orgId = task.getProject().getOrganization().getId(); + } else if (task.getCategory() != null && task.getCategory().getOrganization() != null) { + orgId = task.getCategory().getOrganization().getId(); + } + + if (orgId != null) { + authorizer.execute(userEmail, orgId); + } else if (task.getUser() != null && !task.getUser().getEmail().equals(userEmail)) { + LOGGER.warnf("User %s denied status update on task %d owned by %s", userEmail, id, task.getUser().getEmail()); + throw new SecurityException("Você não tem permissão para atualizar esta tarefa."); + } + task.setStatus(newStatus); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); @@ -29,3 +44,4 @@ public class UpdateTaskStatusUseCase { return output; } } + diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java index 96c5406..59c0e9b 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java @@ -24,21 +24,36 @@ public class UpdateTaskUseCase { private final CategoryRepository categoryRepository; private final UserRepository userRepository; private final UserOrganizationRepository userOrganizationRepository; + private final net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository; public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, CategoryRepository categoryRepository, UserRepository userRepository, - UserOrganizationRepository userOrganizationRepository) { + UserOrganizationRepository userOrganizationRepository, + net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) { this.taskRepository = taskRepository; this.projectRepository = projectRepository; this.categoryRepository = categoryRepository; this.userRepository = userRepository; this.userOrganizationRepository = userOrganizationRepository; + this.organizationRepository = organizationRepository; } public CreateTaskOutput execute(UpdateTaskInput input) { LOGGER.infof("Updating task %d requested by user %s", input.id(), input.userEmail()); Task task = taskRepository.findTaskById(input.id()); + Long currentOrgId = null; + if (task.getOrganization() != null) { + currentOrgId = task.getOrganization().getId(); + } else if (task.getProject() != null && task.getProject().getOrganization() != null) { + currentOrgId = task.getProject().getOrganization().getId(); + } else if (task.getCategory() != null && task.getCategory().getOrganization() != null) { + currentOrgId = task.getCategory().getOrganization().getId(); + } + if (currentOrgId != null && !currentOrgId.equals(input.organizationId())) { + validateRequesterMembership(currentOrgId, input.userEmail()); + } + validateRequesterMembership(input.organizationId(), input.userEmail()); task.setTitle(input.title()); @@ -46,6 +61,10 @@ public class UpdateTaskUseCase { task.setStatus(input.status()); task.setExternalUrl(input.externalUrl()); + if (Objects.nonNull(input.organizationId())) { + organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization); + } + if (Objects.nonNull(input.projectId())) { Project project = projectRepository.findProjectById(input.projectId()) .orElseThrow(() -> { @@ -71,6 +90,18 @@ public class UpdateTaskUseCase { task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId())); if (Objects.nonNull(input.history())) { + long openIntervalCount = input.history().stream().filter(h -> h.endAt() == null).count(); + if (openIntervalCount > 1) { + throw new IllegalArgumentException("Não é permitido mais de um intervalo em andamento ao mesmo tempo."); + } + + for (var hInput : input.history()) { + if (hInput.startAt() != null && hInput.endAt() != null && hInput.endAt().isBefore(hInput.startAt())) { + throw new IllegalArgumentException("Data final do intervalo não pode ser anterior à data inicial."); + } + } + + User defaultWorker = task.getUser(); List updatedHistory = input.history().stream().map(hInput -> { if (Objects.nonNull(hInput.id())) { return task.getHistory().stream() @@ -79,18 +110,23 @@ public class UpdateTaskUseCase { .map(existing -> { existing.setStartAt(hInput.startAt()); existing.setEndAt(hInput.endAt()); + if (existing.getUser() == null) { + existing.setUser(defaultWorker); + } return existing; }) .orElseGet(() -> { TaskHistoryItem newItem = new TaskHistoryItem(); newItem.setStartAt(hInput.startAt()); newItem.setEndAt(hInput.endAt()); + newItem.setUser(defaultWorker); return newItem; }); } else { TaskHistoryItem newItem = new TaskHistoryItem(); newItem.setStartAt(hInput.startAt()); newItem.setEndAt(hInput.endAt()); + newItem.setUser(defaultWorker); return newItem; } }).toList(); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/create/CreateUserUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/create/CreateUserUseCase.java index 8726bd4..04873a6 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/create/CreateUserUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/create/CreateUserUseCase.java @@ -21,6 +21,9 @@ public class CreateUserUseCase { LOGGER.infof("Creating user with email %s", input.email()); User user = CreateUserMapper.toUser(input); + if (user.getPassword() != null && !user.getPassword().isBlank()) { + user.setPassword(org.mindrot.jbcrypt.BCrypt.hashpw(user.getPassword(), org.mindrot.jbcrypt.BCrypt.gensalt(10))); + } User savedUser = userRepository.save(user); LOGGER.infof("User %d created successfully with email %s", savedUser.getId(), savedUser.getEmail()); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/list/ListAllUsersUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/list/ListAllUsersUseCase.java new file mode 100644 index 0000000..79d70f1 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/list/ListAllUsersUseCase.java @@ -0,0 +1,21 @@ +package net.beetechgroup.beetask.usecase.user.list; + +import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import org.jboss.logging.Logger; + +import java.util.List; + +public class ListAllUsersUseCase { + private static final Logger LOGGER = Logger.getLogger(ListAllUsersUseCase.class); + private final UserRepository userRepository; + + public ListAllUsersUseCase(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public List execute() { + LOGGER.info("Executing ListAllUsersUseCase to retrieve all users"); + return userRepository.findAllUsers(); + } +} diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a21f178 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.git diff --git a/frontend/src/components/dashboard/BiUniformCard.tsx b/frontend/src/components/dashboard/BiUniformCard.tsx index 71ee387..aea0f09 100644 --- a/frontend/src/components/dashboard/BiUniformCard.tsx +++ b/frontend/src/components/dashboard/BiUniformCard.tsx @@ -31,6 +31,7 @@ import { isSameMonth, type WorkGoal, } from '../../lib/userGoals' +import { useAuth } from '../../contexts/AuthContext' interface BiUniformCardProps { card: DashboardV2Card @@ -42,6 +43,7 @@ interface BiUniformCardProps { activeTask: Task | null activeSeconds: number workGoal: WorkGoal + globalScope?: 'mine' | 'team' isBeingDragged?: boolean isDragOver?: boolean onToggleTimer: (task: Task) => void @@ -119,6 +121,7 @@ export const BiUniformCard: React.FC = ({ activeTask, activeSeconds, workGoal, + globalScope = 'team', isBeingDragged = false, isDragOver = false, onToggleTimer, @@ -131,11 +134,30 @@ export const BiUniformCard: React.FC = ({ onDrop, onDragEnd, }) => { + const { user } = useAuth() const theme = THEME_STYLES[card.colorTheme || 'brand'] const now = new Date() - // 1. Filter Tasks based on card's combined dimensions + // Resolve scope: Card's specific scope takes precedence, otherwise fallback to page's globalScope + const effectiveScope: 'mine' | 'team' = + card.scope && card.scope !== 'INHERIT' + ? card.scope === 'MINE' + ? 'mine' + : 'team' + : globalScope + + // 1. Filter Tasks based on scope and card's combined dimensions const filteredTasks = tasks.filter((t) => { + // Scope filter (if 'mine', only user's tasks) + if (effectiveScope === 'mine') { + const isMine = Boolean( + t.user?.email && + user?.email && + t.user.email.toLowerCase() === user.email.toLowerCase() + ) + if (!isMine) return false + } + if (card.projectId && t.project?.id !== card.projectId) return false if (card.categoryId && t.category?.id !== card.categoryId) return false if (card.statusFilter && card.statusFilter !== 'ALL') { @@ -351,24 +373,35 @@ export const BiUniformCard: React.FC = ({ } case 'ACTIVE_TIMER': { - const targetTask = activeTask || tasks[0] + const isMyActiveTask = Boolean( + activeTask && + user?.email && + activeTask.user?.email && + activeTask.user.email.toLowerCase() === user.email.toLowerCase() + ) + const currentActive = isMyActiveTask ? activeTask : null + const myTasks = tasks.filter((t) => { + if (!user?.email || !t.user?.email) return false + return t.user.email.toLowerCase() === user.email.toLowerCase() + }) + const targetTask = currentActive || myTasks.find((t) => t.status === 'IN_PROGRESS') || myTasks.find((t) => t.status === 'NOT_STARTED') || myTasks[0] return (
- {activeTask ? formatSecondsToTimer(activeSeconds) : '00:00:00'} + {currentActive ? formatSecondsToTimer(activeSeconds) : '00:00:00'} {targetTask && (

- {activeTask ? activeTask.title : targetTask ? targetTask.title : 'Nenhuma tarefa pronta'} + {currentActive ? currentActive.title : targetTask ? targetTask.title : 'Nenhuma tarefa pronta'}

) @@ -524,6 +557,23 @@ export const BiUniformCard: React.FC = ({ {/* Footer: Compact Dimension Tags */}
+ {/* Scope Tag */} + + {effectiveScope === 'mine' ? '👤 Minhas' : '👥 Equipe'} + {card.scope && card.scope !== 'INHERIT' && ' • fixo'} + + {periodLabels[card.period]} diff --git a/frontend/src/components/dashboard/CreateBiCardModal.tsx b/frontend/src/components/dashboard/CreateBiCardModal.tsx index 26cdb2c..59d478d 100644 --- a/frontend/src/components/dashboard/CreateBiCardModal.tsx +++ b/frontend/src/components/dashboard/CreateBiCardModal.tsx @@ -11,7 +11,7 @@ import { ListOrdered, Layers, } from 'lucide-react' -import type { DashboardV2Card, BiMetricType, BiPeriodFilter } from '../../lib/userPreferences' +import type { DashboardV2Card, BiMetricType, BiPeriodFilter, BiCardScope } from '../../lib/userPreferences' import type { Project, Category } from '../../types/task' interface CreateBiCardModalProps { @@ -103,6 +103,7 @@ export const CreateBiCardModal: React.FC = ({ const [categoryId, setCategoryId] = useState(undefined) const [statusFilter, setStatusFilter] = useState<'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'>('ALL') const [colorTheme, setColorTheme] = useState('brand') + const [scope, setScope] = useState('INHERIT') useEffect(() => { if (editingCard) { @@ -113,6 +114,7 @@ export const CreateBiCardModal: React.FC = ({ setCategoryId(editingCard.categoryId) setStatusFilter(editingCard.statusFilter || 'ALL') setColorTheme(editingCard.colorTheme || 'brand') + setScope(editingCard.scope || 'INHERIT') } else { setTitle('') setMetricType('HOURS_TRACKED') @@ -121,6 +123,7 @@ export const CreateBiCardModal: React.FC = ({ setCategoryId(undefined) setStatusFilter('ALL') setColorTheme('brand') + setScope('INHERIT') } }, [editingCard, isOpen]) @@ -138,6 +141,7 @@ export const CreateBiCardModal: React.FC = ({ categoryId, statusFilter, colorTheme, + scope, } onSave(card) onClose() @@ -313,6 +317,23 @@ export const CreateBiCardModal: React.FC = ({ ))}
+ + {/* Data Scope Filter */} +
+ + +
diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index f6648ab..6f907b0 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -7,7 +7,7 @@ export const AppShell = () => { const [isMobileOpen, setIsMobileOpen] = useState(false) return ( -
+
{/* Decorative ambient background glows matching Landing Page aesthetic */}
@@ -18,14 +18,14 @@ export const AppShell = () => { onCloseMobile={() => setIsMobileOpen(false)} /> - {/* Main Content Area */} -
+ {/* Main Content Area - only this scrolls */} +
{/* Mobile-only compact top bar to open drawer on smartphones */} -
+
+ )} {initialTask && ( - + Tempo total: {formatSecondsToHuman(totalCalculatedSeconds)} )} diff --git a/frontend/src/components/tasks/TaskCard.tsx b/frontend/src/components/tasks/TaskCard.tsx index a216c60..237770f 100644 --- a/frontend/src/components/tasks/TaskCard.tsx +++ b/frontend/src/components/tasks/TaskCard.tsx @@ -1,13 +1,16 @@ import React from 'react' -import { Play, Square, Clock, Folder, Tag, MoreVertical, CircleDot, ExternalLink } from 'lucide-react' +import { Play, Square, Clock, Folder, Tag, MoreVertical, CircleDot, ExternalLink, Trash2 } from 'lucide-react' import type { Task } from '../../types/task' import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils' +import { useAuth } from '../../contexts/AuthContext' +import { canDeleteTask } from '../../lib/taskPermissions' interface TaskCardProps { task: Task onToggleTimer: (task: Task) => void onEdit?: (task: Task) => void onOpenHistory?: (task: Task) => void + onDelete?: (task: Task) => void isTrackingThis?: boolean currentSeconds?: number } @@ -17,9 +20,11 @@ export const TaskCard: React.FC = ({ onToggleTimer, onEdit, onOpenHistory, + onDelete, isTrackingThis, currentSeconds, }) => { + const { user, activeOrg } = useAuth() const running = isTrackingThis ?? isTaskRunning(task) const totalSeconds = currentSeconds !== undefined ? currentSeconds : calculateTaskTotalSeconds(task) const [menuOpen, setMenuOpen] = React.useState(false) @@ -90,7 +95,7 @@ export const TaskCard: React.FC = ({
{/* More Actions Menu */} - {(onEdit || onOpenHistory) && ( + {(onEdit || onOpenHistory || (onDelete && canDeleteTask(task, user, activeOrg))) && (
)} + + {onDelete && canDeleteTask(task, user, activeOrg) && ( + + )}
)} diff --git a/frontend/src/components/tasks/TaskKanbanView.tsx b/frontend/src/components/tasks/TaskKanbanView.tsx index 52bc399..7b98819 100644 --- a/frontend/src/components/tasks/TaskKanbanView.tsx +++ b/frontend/src/components/tasks/TaskKanbanView.tsx @@ -10,6 +10,7 @@ interface TaskKanbanViewProps { onStatusChange: (task: Task, newStatus: TaskStatus) => void onEditTask: (task: Task) => void onOpenHistory?: (task: Task) => void + onDeleteTask?: (task: Task) => void onAddTaskToStatus: (status: TaskStatus) => void activeTaskId?: number | null activeTaskSeconds?: number @@ -69,6 +70,7 @@ export const TaskKanbanView: React.FC = ({ onStatusChange, onEditTask, onOpenHistory, + onDeleteTask, onAddTaskToStatus, activeTaskId, activeTaskSeconds, @@ -267,6 +269,7 @@ export const TaskKanbanView: React.FC = ({ onToggleTimer={onToggleTimer} onEdit={onEditTask} onOpenHistory={onOpenHistory} + onDelete={onDeleteTask} isTrackingThis={activeTaskId === task.id} currentSeconds={activeTaskId === task.id ? activeTaskSeconds : undefined} /> diff --git a/frontend/src/components/tasks/TaskListView.tsx b/frontend/src/components/tasks/TaskListView.tsx index a7b2567..1495d71 100644 --- a/frontend/src/components/tasks/TaskListView.tsx +++ b/frontend/src/components/tasks/TaskListView.tsx @@ -1,8 +1,10 @@ import React from 'react' -import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2, ExternalLink } from 'lucide-react' +import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2, ExternalLink, Trash2 } from 'lucide-react' import type { Task, TaskStatus } from '../../types/task' -import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils' +import { calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils' import { CustomSelect } from '../ui/Select' +import { useAuth } from '../../contexts/AuthContext' +import { canDeleteTask } from '../../lib/taskPermissions' interface TaskListViewProps { tasks: Task[] @@ -10,6 +12,7 @@ interface TaskListViewProps { onStatusChange: (task: Task, newStatus: TaskStatus) => void onEditTask: (task: Task) => void onOpenHistory?: (task: Task) => void + onDeleteTask?: (task: Task) => void activeTaskId?: number | null activeTaskSeconds?: number } @@ -21,9 +24,11 @@ export const TaskListView: React.FC = ({ onStatusChange, onEditTask, onOpenHistory, + onDeleteTask, activeTaskId, activeTaskSeconds, }) => { + const { user, activeOrg } = useAuth() const [activeMenuId, setActiveMenuId] = React.useState(null) return ( @@ -43,10 +48,9 @@ export const TaskListView: React.FC = ({ {tasks.map((task) => { - const isRunning = - activeTaskId === task.id || (activeTaskId === undefined && isTaskRunning(task)) + const isRunning = Boolean(activeTaskId && activeTaskId === task.id) const totalSec = - activeTaskId === task.id && activeTaskSeconds !== undefined + isRunning && activeTaskSeconds !== undefined ? activeTaskSeconds : calculateTaskTotalSeconds(task) @@ -247,6 +251,19 @@ export const TaskListView: React.FC = ({ Concluir + {onDeleteTask && canDeleteTask(task, user, activeOrg) && ( + + )}
)} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 17b57ab..fd0c66f 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -23,6 +23,7 @@ interface AuthContextType { register: (name: string, email: string, password: string) => Promise logout: () => void refreshUser: () => Promise + updateUser: (updated: Partial) => void setActiveOrg: (id: number) => void } @@ -148,6 +149,15 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { } } + const updateUser = useCallback((updated: Partial) => { + setUser((prev) => { + if (!prev) return null + const next = { ...prev, ...updated } + localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(next)) + return next + }) + }, []) + const setActiveOrg = (id: number) => { setActiveOrgId(id) localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, id.toString()) @@ -169,6 +179,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { register, logout, refreshUser, + updateUser, setActiveOrg, }} > diff --git a/frontend/src/contexts/TimerContext.tsx b/frontend/src/contexts/TimerContext.tsx index 09fcabe..ca3382b 100644 --- a/frontend/src/contexts/TimerContext.tsx +++ b/frontend/src/contexts/TimerContext.tsx @@ -15,29 +15,65 @@ interface TimerContextType { const TimerContext = createContext(undefined) export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { activeOrg } = useAuth() + const { user, activeOrg } = useAuth() const [activeTask, setActiveTask] = useState(null) const [activeSeconds, setActiveSeconds] = useState(0) // Track the baseline seconds and client-side timestamp when task data was fetched/started const baseSecondsRef = useRef(0) const receivedAtRef = useRef(Date.now()) + const activeTaskRef = useRef(null) + + useEffect(() => { + activeTaskRef.current = activeTask + }, [activeTask]) + + // Check if task belongs to the logged-in user (as assignee or creator) + const isMyTask = useCallback( + (t: Task) => { + if (!user?.email) return false + const userEmail = user.email.toLowerCase() + const matchesAssignee = t.user?.email && t.user.email.toLowerCase() === userEmail + const matchesCreator = t.createdBy?.email && t.createdBy.email.toLowerCase() === userEmail + return Boolean(matchesAssignee || matchesCreator) + }, + [user?.email] + ) // Sync state whenever tasks list updates - const syncWithTasks = useCallback((tasks: Task[]) => { - const running = tasks.find((t) => isTaskRunning(t)) - if (running) { - setActiveTask(running) - const base = calculateBaseSeconds(running) - baseSecondsRef.current = base - receivedAtRef.current = Date.now() - setActiveSeconds(base) - } else { - setActiveTask(null) - baseSecondsRef.current = 0 - setActiveSeconds(0) - } - }, []) + const syncWithTasks = useCallback( + (tasks: Task[]) => { + // ONLY consider tasks assigned to the current logged-in user + const running = tasks.find((t) => isTaskRunning(t) && isMyTask(t)) + if (running) { + activeTaskRef.current = running + setActiveTask((prev) => { + if (prev?.id === running.id && prev.status === running.status) { + const prevRunningItem = prev.history?.find((h) => !h.endAt) + const currRunningItem = running.history?.find((h) => !h.endAt) + if (prevRunningItem?.id === currRunningItem?.id && prevRunningItem?.startAt === currRunningItem?.startAt) { + return prev + } + } + return running + }) + const base = calculateBaseSeconds(running) + baseSecondsRef.current = base + receivedAtRef.current = Date.now() + setActiveSeconds(base) + } else { + // If syncing a single task and it's not the active task, don't accidentally clear + if (tasks.length === 1 && activeTaskRef.current && tasks[0].id !== activeTaskRef.current.id) { + return + } + activeTaskRef.current = null + setActiveTask(null) + baseSecondsRef.current = 0 + setActiveSeconds(0) + } + }, + [isMyTask] + ) // Auto-fetch active running task on mount or workspace change useEffect(() => { @@ -48,7 +84,7 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre .listMyTasks({ organizationId: activeOrg.id }) .then((tasks) => { if (!isMounted) return - const running = tasks.find((t) => isTaskRunning(t)) + const running = tasks.find((t) => isTaskRunning(t) && isMyTask(t)) if (running) { syncWithTasks(tasks) } @@ -102,15 +138,16 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre const startTimer = useCallback( async (task: Task): Promise => { // If there was an active task running different from this one, stop it first - if (activeTask && activeTask.id !== task.id) { + if (activeTaskRef.current && activeTaskRef.current.id !== task.id) { try { - await taskService.stopTask(activeTask.id) + await taskService.stopTask(activeTaskRef.current.id) } catch (e) { console.warn('Could not stop previously running task', e) } } const updated = await taskService.startTask(task.id) + activeTaskRef.current = updated setActiveTask(updated) const base = calculateBaseSeconds(updated) baseSecondsRef.current = base @@ -118,21 +155,22 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre setActiveSeconds(base) return updated }, - [activeTask] + [] ) const stopTimer = useCallback( async (task?: Task): Promise => { - const targetId = task ? task.id : activeTask?.id + const targetId = task ? task.id : activeTaskRef.current?.id if (!targetId) return null const updated = await taskService.stopTask(targetId) + activeTaskRef.current = null setActiveTask(null) baseSecondsRef.current = 0 setActiveSeconds(0) return updated }, - [activeTask] + [] ) return ( diff --git a/frontend/src/lib/taskPermissions.ts b/frontend/src/lib/taskPermissions.ts new file mode 100644 index 0000000..c0e0793 --- /dev/null +++ b/frontend/src/lib/taskPermissions.ts @@ -0,0 +1,30 @@ +import type { Task } from '../types/task' +import type { LoginResponse, OrganizationMembership } from '../services/authService' + +/** + * Checks whether the current user is allowed to delete a task: + * - Must be an ADMIN or OWNER of the organization to which the task belongs, OR + * - Must be the owner/creator of the task. + */ +export function canDeleteTask( + task: Task, + user: LoginResponse | null, + activeOrg: OrganizationMembership | null +): boolean { + if (!user) return false + + // 1. Is the user the creator or owner of the task? + const isCreator = Boolean( + (task.createdBy?.email && task.createdBy.email.toLowerCase() === user.email.toLowerCase()) || + (task.user?.email && task.user.email.toLowerCase() === user.email.toLowerCase()) + ) + + if (isCreator) return true + + // 2. Is the user an admin or owner of the active organization? + const isOrgAdmin = Boolean( + activeOrg?.roles?.some((role) => role === 'ADMIN' || role === 'OWNER') + ) + + return isOrgAdmin +} diff --git a/frontend/src/lib/userPreferences.ts b/frontend/src/lib/userPreferences.ts index ddae96c..ce08b4c 100644 --- a/frontend/src/lib/userPreferences.ts +++ b/frontend/src/lib/userPreferences.ts @@ -27,6 +27,8 @@ export type BiMetricType = export type BiPeriodFilter = 'ALL' | 'TODAY' | 'WEEK' | 'MONTH' +export type BiCardScope = 'INHERIT' | 'MINE' | 'TEAM' + export interface DashboardV2Card { id: string title: string @@ -36,6 +38,7 @@ export interface DashboardV2Card { categoryId?: number statusFilter?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED' colorTheme?: 'brand' | 'accent' | 'emerald' | 'indigo' | 'rose' | 'cyan' + scope?: BiCardScope } export const DEFAULT_V2_CARDS: DashboardV2Card[] = [ diff --git a/frontend/src/pages/AnalyticsPage.tsx b/frontend/src/pages/AnalyticsPage.tsx index 2bf3320..ea9075c 100644 --- a/frontend/src/pages/AnalyticsPage.tsx +++ b/frontend/src/pages/AnalyticsPage.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react' +import { useSearchParams } from 'react-router-dom' import { Clock, CheckCircle2, @@ -10,6 +11,7 @@ import { Sparkles, Activity, AlertCircle, + User, } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' import { taskService } from '../services/taskService' @@ -20,7 +22,7 @@ import type { Task, Project, Category } from '../types/task' import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils' export const AnalyticsPage: React.FC = () => { - const { activeOrg } = useAuth() + const { user, activeOrg } = useAuth() const [tasks, setTasks] = useState([]) const [projects, setProjects] = useState([]) @@ -31,6 +33,24 @@ export const AnalyticsPage: React.FC = () => { const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) + // Scope Filter: 'ALL' | 'MINE' | email do membro + const [searchParams, setSearchParams] = useSearchParams() + const initialScope = searchParams.get('member') || 'ALL' + const [selectedMember, setSelectedMember] = useState(initialScope) + + const handleMemberChange = (value: string) => { + setSelectedMember(value) + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + if (value === 'ALL') next.delete('member') + else next.set('member', value) + return next + }, + { replace: true } + ) + } + const activeOrgId = activeOrg?.id const loadData = useCallback(async () => { @@ -61,15 +81,25 @@ export const AnalyticsPage: React.FC = () => { loadData() }, [loadData]) - // Aggregate high-level KPIs - const kpis = useMemo(() => { - const totalTasks = tasks.length - const completedTasks = tasks.filter((t) => t.status === 'COMPLETED').length - const inProgressTasks = tasks.filter((t) => t.status === 'IN_PROGRESS').length - const notStartedTasks = tasks.filter((t) => t.status === 'NOT_STARTED').length - const canceledTasks = tasks.filter((t) => t.status === 'CANCELED').length + // Filter tasks based on selected member scope + const scopedTasks = useMemo(() => { + if (selectedMember === 'ALL') return tasks + const targetEmail = selectedMember === 'MINE' ? user?.email : selectedMember + if (!targetEmail) return tasks + return tasks.filter( + (t) => t.user?.email && t.user.email.toLowerCase() === targetEmail.toLowerCase() + ) + }, [tasks, selectedMember, user?.email]) - const totalSecondsTracked = tasks.reduce( + // Aggregate high-level KPIs based on scopedTasks + const kpis = useMemo(() => { + const totalTasks = scopedTasks.length + const completedTasks = scopedTasks.filter((t) => t.status === 'COMPLETED').length + const inProgressTasks = scopedTasks.filter((t) => t.status === 'IN_PROGRESS').length + const notStartedTasks = scopedTasks.filter((t) => t.status === 'NOT_STARTED').length + const canceledTasks = scopedTasks.filter((t) => t.status === 'CANCELED').length + + const totalSecondsTracked = scopedTasks.reduce( (acc, t) => acc + calculateTaskTotalSeconds(t), 0 ) @@ -80,7 +110,7 @@ export const AnalyticsPage: React.FC = () => { const avgSecondsPerCompletedTask = completedTasks > 0 ? Math.round( - tasks + scopedTasks .filter((t) => t.status === 'COMPLETED') .reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) / completedTasks ) @@ -96,7 +126,7 @@ export const AnalyticsPage: React.FC = () => { completionRate, avgSecondsPerCompletedTask, } - }, [tasks]) + }, [scopedTasks]) // Project breakdown const projectBreakdown = useMemo(() => { @@ -109,7 +139,7 @@ export const AnalyticsPage: React.FC = () => { let unassignedSeconds = 0 let unassignedTasks = 0 - tasks.forEach((t) => { + scopedTasks.forEach((t) => { const sec = calculateTaskTotalSeconds(t) if (t.project?.id && map.has(t.project.id)) { const item = map.get(t.project.id)! @@ -131,7 +161,7 @@ export const AnalyticsPage: React.FC = () => { } return list.sort((a, b) => b.seconds - a.seconds) - }, [projects, tasks]) + }, [projects, scopedTasks]) // Category breakdown const categoryBreakdown = useMemo(() => { @@ -144,7 +174,7 @@ export const AnalyticsPage: React.FC = () => { let unassignedSeconds = 0 let unassignedTasks = 0 - tasks.forEach((t) => { + scopedTasks.forEach((t) => { const sec = calculateTaskTotalSeconds(t) if (t.category?.id && map.has(t.category.id)) { const item = map.get(t.category.id)! @@ -166,7 +196,7 @@ export const AnalyticsPage: React.FC = () => { } return list.sort((a, b) => b.seconds - a.seconds) - }, [categories, tasks]) + }, [categories, scopedTasks]) // Member breakdown const memberBreakdown = useMemo(() => { @@ -252,6 +282,68 @@ export const AnalyticsPage: React.FC = () => {
)} + {/* Scope Selector: Equipe Completa vs Apenas Minhas vs Membro Específico */} +
+
+ + Filtro de Membro: + +
+ + +
+ + {/* Specific Member Dropdown */} +
+ +
+
+ +
+ + + {selectedMember === 'ALL' + ? 'Calculando métricas globais consolidadas do workspace' + : selectedMember === 'MINE' + ? 'Calculando métricas estritamente do seu perfil' + : `Filtrando métricas para o membro selecionado`} + +
+
+ {loading ? (
{[1, 2, 3, 4].map((i) => ( @@ -274,9 +366,14 @@ export const AnalyticsPage: React.FC = () => {
-

- {formatSecondsToTimer(kpis.totalSecondsTracked)} -

+
+

+ {formatSecondsToTimer(kpis.totalSecondsTracked)} +

+ + {selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'} + +

Equivalente a {formatSecondsToHuman(kpis.totalSecondsTracked)}

@@ -292,9 +389,14 @@ export const AnalyticsPage: React.FC = () => {
-

- {kpis.completionRate}% -

+
+

+ {kpis.completionRate}% +

+ + {selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'} + +

{kpis.completedTasks} de {kpis.totalTasks} demandas finalizadas

@@ -310,9 +412,14 @@ export const AnalyticsPage: React.FC = () => {
-

- {kpis.inProgressTasks} tarefas -

+
+

+ {kpis.inProgressTasks} tarefas +

+ + {selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'} + +

{kpis.notStartedTasks} no backlog aguardando início

@@ -328,9 +435,14 @@ export const AnalyticsPage: React.FC = () => {
-

- {formatSecondsToHuman(kpis.avgSecondsPerCompletedTask)} -

+
+

+ {formatSecondsToHuman(kpis.avgSecondsPerCompletedTask)} +

+ + {selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'} + +

Tempo médio gasto em cada entrega concluída

diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a36087d..478a1b0 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { Clock, CheckCircle2, @@ -21,6 +21,8 @@ import { Settings2, BarChart3, ExternalLink, + User, + Users, } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' import { useTimer } from '../contexts/TimerContext' @@ -40,6 +42,15 @@ export const DashboardPage: React.FC = () => { const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer() const navigate = useNavigate() + // Check if active task belongs to current user (as assignee or creator) + const isMyActiveTask = Boolean( + activeTask && + user?.email && + ((activeTask.user?.email && activeTask.user.email.toLowerCase() === user.email.toLowerCase()) || + (activeTask.createdBy?.email && activeTask.createdBy.email.toLowerCase() === user.email.toLowerCase())) + ) + const currentActiveTask = isMyActiveTask ? activeTask : null + const [tasks, setTasks] = useState([]) const [projects, setProjects] = useState([]) const [loading, setLoading] = useState(true) @@ -71,6 +82,29 @@ export const DashboardPage: React.FC = () => { // Modal const [isModalOpen, setIsModalOpen] = useState(false) + // Scope Toggle State: 'mine' (Individual) vs 'team' (Equipe) + const [searchParams, setSearchParams] = useSearchParams() + const initialScope = + (searchParams.get('scope') as 'mine' | 'team') || + (localStorage.getItem('beetask_dashboard_scope') as 'mine' | 'team') || + 'mine' + const [viewScope, setViewScope] = useState<'mine' | 'team'>(initialScope) + + const handleScopeChange = (newScope: 'mine' | 'team') => { + setViewScope(newScope) + try { + localStorage.setItem('beetask_dashboard_scope', newScope) + } catch (_) {} + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + next.set('scope', newScope) + return next + }, + { replace: true } + ) + } + const activeOrgId = activeOrg?.id const loadData = useCallback(async () => { @@ -102,21 +136,39 @@ export const DashboardPage: React.FC = () => { loadData() }, [loadData]) - // KPIs + // Filter user's own tasks + const myTasks = useMemo(() => { + return tasks.filter((t) => { + if (!user?.email) return false + return Boolean( + t.user?.email && + user?.email && + t.user.email.toLowerCase() === user.email.toLowerCase() + ) + }) + }, [tasks, user]) + + // Scope tasks based on viewScope + const scopeTasks = viewScope === 'mine' ? myTasks : tasks + + // KPIs dynamically calculated based on active scope const stats = useMemo(() => { - const inProgress = tasks.filter((t) => t.status === 'IN_PROGRESS').length - const completed = tasks.filter((t) => t.status === 'COMPLETED').length - const notStarted = tasks.filter((t) => t.status === 'NOT_STARTED').length - const totalSeconds = tasks.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) + const inProgress = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length + const completed = scopeTasks.filter((t) => t.status === 'COMPLETED').length + const notStarted = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length + const totalSeconds = scopeTasks.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) + + const uniqueProjectIds = new Set(scopeTasks.map((t) => t.project?.id).filter(Boolean)) + const projectsCount = viewScope === 'mine' ? uniqueProjectIds.size : projects.length return { inProgress, completed, notStarted, totalSeconds, - projectsCount: projects.length, + projectsCount, } - }, [tasks, projects]) + }, [scopeTasks, projects, viewScope]) // Work Goal Progress in Current Period const goalProgress = useMemo(() => { @@ -124,7 +176,7 @@ export const DashboardPage: React.FC = () => { const workedSeconds = calculateUserPeriodWorkedSeconds( tasks, workGoal?.period || 'DAILY', - activeTask?.id, + currentActiveTask?.id, activeSeconds ) const percent = Math.min(100, Math.round((workedSeconds / targetSeconds) * 100)) @@ -152,17 +204,17 @@ export const DashboardPage: React.FC = () => { targetHours: workGoal?.targetHours || 8, isCompleted: workedSeconds >= targetSeconds, } - }, [tasks, workGoal, activeTask?.id, activeSeconds]) + }, [tasks, workGoal, currentActiveTask?.id, activeSeconds]) - // Recent 5 tasks + // Recent 5 tasks based on active scope const recentTasks = useMemo(() => { - return [...tasks].slice(0, 5) - }, [tasks]) + return [...scopeTasks].slice(0, 5) + }, [scopeTasks]) // Toggle timer on a task const handleToggleTimer = async (task: Task) => { try { - if (activeTask?.id === task.id) { + if (currentActiveTask?.id === task.id) { const stopped = await stopTimer(task) if (stopped) { setTasks((prev) => prev.map((t) => (t.id === stopped.id ? stopped : t))) @@ -201,10 +253,19 @@ export const DashboardPage: React.FC = () => { setTasks((prev) => [created, ...prev]) } - // Priority task to focus on if none running + // Priority task to focus on if none running (only user's own tasks) const suggestedTask = useMemo(() => { - return tasks.find((t) => t.status === 'IN_PROGRESS') || tasks.find((t) => t.status === 'NOT_STARTED') || tasks[0] - }, [tasks]) + const myTasks = tasks.filter((t) => { + if (!user?.email || !t.user?.email) return false + return t.user.email.toLowerCase() === user.email.toLowerCase() + }) + return ( + myTasks.find((t) => t.status === 'IN_PROGRESS') || + myTasks.find((t) => t.status === 'NOT_STARTED') || + myTasks[0] || + null + ) + }, [tasks, user]) const firstName = user?.name ? user.name.split(' ')[0] : 'Desenvolvedor' @@ -288,6 +349,64 @@ export const DashboardPage: React.FC = () => {
)} + {/* Scope Selector: Individual (Minhas Tarefas) vs Equipe (Workspace) */} +
+
+ + Modo de Visualização: + +
+ + +
+
+ +
+ + + {viewScope === 'mine' + ? 'KPIs e métricas filtrados estritamente para demandas atribuídas a você' + : 'KPIs e métricas consolidados de todos os integrantes do workspace'} + +
+
+ {/* 4 Premium KPI Cards with shadow-panel & backdrop-blur */} {loading ? (
@@ -303,17 +422,28 @@ export const DashboardPage: React.FC = () => { {/* In Progress */}
- Tarefas em Andamento +
+ + {viewScope === 'mine' ? 'Suas Tarefas em Andamento' : 'Tarefas da Equipe em Andamento'} + +
-

- {stats.inProgress} -

+
+

+ {stats.inProgress} +

+ + {viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'} + +

- {stats.notStarted} tarefas aguardando no backlog + {viewScope === 'mine' + ? `${stats.notStarted} na sua fila aguardando` + : `${stats.notStarted} tarefas aguardando no backlog`}

@@ -321,17 +451,26 @@ export const DashboardPage: React.FC = () => { {/* Completed */}
- Entregas Concluídas + + {viewScope === 'mine' ? 'Suas Entregas Concluídas' : 'Entregas Concluídas da Equipe'} +
-

- {stats.completed} -

+
+

+ {stats.completed} +

+ + {viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'} + +

- de {tasks.length} demandas cadastradas + {viewScope === 'mine' + ? `de ${myTasks.length} demandas suas` + : `de ${tasks.length} demandas cadastradas`}

@@ -339,17 +478,26 @@ export const DashboardPage: React.FC = () => { {/* Time Tracked */}
- Tempo Total Dedicado + + {viewScope === 'mine' ? 'Seu Tempo Dedicado' : 'Tempo Total da Equipe'} +
-

- {formatSecondsToTimer(stats.totalSeconds)} -

+
+

+ {formatSecondsToTimer(stats.totalSeconds)} +

+ + {viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'} + +

- {formatSecondsToHuman(stats.totalSeconds)} auditadas no time + {viewScope === 'mine' + ? `${formatSecondsToHuman(stats.totalSeconds)} auditadas por você` + : `${formatSecondsToHuman(stats.totalSeconds)} auditadas no time`}

@@ -357,17 +505,26 @@ export const DashboardPage: React.FC = () => { {/* Projects */}
- Projetos Ativos + + {viewScope === 'mine' ? 'Seus Projetos Ativos' : 'Projetos da Organização'} +
-

- {stats.projectsCount} -

+
+

+ {stats.projectsCount} +

+ + {viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'} + +

- Iniciativas ativas no workspace + {viewScope === 'mine' + ? 'Iniciativas com tarefas suas' + : 'Iniciativas ativas no workspace'}

@@ -394,43 +551,43 @@ export const DashboardPage: React.FC = () => {
- {activeTask ? 'CRONOMETRANDO' : 'EM ESPERA'} + {currentActiveTask ? 'CRONOMETRANDO' : 'EM ESPERA'}
{/* Active or Suggested Task Info Box */} - {activeTask ? ( + {currentActiveTask ? (
- {activeTask.project && ( + {currentActiveTask.project && ( - {activeTask.project.name} + {currentActiveTask.project.name} )} - {activeTask.category && ( + {currentActiveTask.category && ( - {activeTask.category.name} + {currentActiveTask.category.name} )}

- {activeTask.title} + {currentActiveTask.title}

) : suggestedTask ? ( @@ -458,19 +615,19 @@ export const DashboardPage: React.FC = () => { {/* Big Bold Timer Display */}
- {activeTask ? 'Tempo Decorrido na Tarefa' : 'Pronto para Iniciar'} + {currentActiveTask ? 'Tempo Decorrido na Tarefa' : 'Pronto para Iniciar'}

- {activeTask ? formatSecondsToTimer(activeSeconds) : '00:00:00'} + {currentActiveTask ? formatSecondsToTimer(activeSeconds) : '00:00:00'}

{/* Timer Actions */} - {activeTask ? ( + {currentActiveTask ? ( + +
+ + +
+ + + {viewScope === 'mine' + ? 'Exibindo cards com dados das suas tarefas atribuídas' + : 'Exibindo cards com métricas consolidadas de todo o workspace'} + +
+ + {/* Uniform Modular Grid */} {loading ? (
@@ -393,6 +462,7 @@ export const DashboardV2Page: React.FC = () => { activeTask={activeTask} activeSeconds={activeSeconds} workGoal={workGoal} + globalScope={viewScope} isBeingDragged={draggedCardId === card.id} isDragOver={dragOverCardId === card.id} onToggleTimer={handleToggleTimer} diff --git a/frontend/src/pages/OrganizationsPage.tsx b/frontend/src/pages/OrganizationsPage.tsx index 937d297..1f52fdb 100644 --- a/frontend/src/pages/OrganizationsPage.tsx +++ b/frontend/src/pages/OrganizationsPage.tsx @@ -1,38 +1,71 @@ -import { useState, type FormEvent } from 'react' +import { useState, useEffect, useCallback, type FormEvent } from 'react' import { useNavigate } from 'react-router-dom' -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Button, - Input, - Chip, - ChipLabel, - Alert, - AlertTitle, - AlertDescription, -} from '@heroui/react' import { Building2, Plus, ArrowRight, Users, ShieldCheck, + Search, + Clock, + CheckCircle2, + XCircle, + Send, + Check, + Loader2, + Inbox, } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' -import { organizationService } from '../services/organizationService' +import { + organizationService, + type SearchOrgOutput, + type JoinRequestOutput, +} from '../services/organizationService' export const OrganizationsPage = () => { const { user, activeOrg, setActiveOrg, refreshUser } = useAuth() const navigate = useNavigate() + const [activeTab, setActiveTab] = useState<'workspaces' | 'join' | 'create'>( + user?.organizations && user.organizations.length > 0 ? 'workspaces' : 'join' + ) + + // Create Org states const [newOrgName, setNewOrgName] = useState('') const [isCreating, setIsCreating] = useState(false) + + // Search & Join states + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + const [joiningOrgId, setJoiningOrgId] = useState(null) + + // My join requests + const [myRequests, setMyRequests] = useState([]) + const [loadingRequests, setLoadingRequests] = useState(false) + + // Feedback notifications const [errorMessage, setErrorMessage] = useState(null) const [successMessage, setSuccessMessage] = useState(null) + // Load user's join requests + const loadMyRequests = useCallback(async () => { + setLoadingRequests(true) + try { + const reqs = await organizationService.listMyRequests() + setMyRequests(reqs || []) + } catch (err) { + console.error('Error fetching join requests', err) + } finally { + setLoadingRequests(false) + } + }, []) + + useEffect(() => { + loadMyRequests() + }, [loadMyRequests]) + const handleCreateOrg = async (e: FormEvent) => { e.preventDefault() if (!newOrgName.trim()) return @@ -63,180 +96,574 @@ export const OrganizationsPage = () => { navigate('/dashboard') } + const handleSearch = async (e?: FormEvent) => { + if (e) e.preventDefault() + setIsSearching(true) + setHasSearched(true) + setErrorMessage(null) + + try { + const results = await organizationService.search(searchQuery.trim()) + setSearchResults(results || []) + } catch (err: unknown) { + console.error(err) + setErrorMessage('Falha ao buscar organizações.') + } finally { + setIsSearching(false) + } + } + + const handleRequestJoin = async (orgId: number, orgName: string) => { + setJoiningOrgId(orgId) + setErrorMessage(null) + setSuccessMessage(null) + + try { + await organizationService.requestJoin(orgId) + setSuccessMessage(`Solicitação enviada para "${orgName}" com sucesso! Aguarde a aprovação do proprietário.`) + await loadMyRequests() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Falha ao solicitar entrada.' + setErrorMessage(msg) + } finally { + setJoiningOrgId(null) + } + } + + const handleEnterApprovedOrg = async (orgId: number) => { + try { + await refreshUser() + setActiveOrg(orgId) + navigate('/dashboard') + } catch (err) { + console.error(err) + } + } + + const pendingRequestsCount = myRequests.filter((r) => r.status === 'PENDING').length + return ( -
+
{/* Page Header */}
-
+
- Gestão de Workspaces Corporativos + Gestão de Workspaces & Organizações
-

- Seus Workspaces & Organizações +

+ Workspaces Corporativos

-

- Selecione uma organização ativa para acessar projetos, tarefas e métricas da equipe, - ou crie um novo workspace dedicado para sua empresa ou squad. +

+ Navegue entre seus workspaces ativos, solicite entrada em uma organização existente ou + crie um novo ambiente dedicado para seu squad ou empresa.

{/* Notifications */} {errorMessage && ( - +
+
- Não foi possível criar - {errorMessage} +

Ocorreu um erro

+

{errorMessage}

- + +
)} {successMessage && ( - +
+
- Sucesso - {successMessage} +

Sucesso

+

{successMessage}

- + +
)} -
- {/* Existing Organizations (7 cols) */} -
-
-

- - Workspaces Vinculados ({user?.organizations?.length || 0}) -

+ {/* Tabs Navigation */} +
+ + + + + +
+ + {/* TAB 1: WORKSPACES VINCULADOS */} + {activeTab === 'workspaces' && ( +
+
+
+

+ + Workspaces Vinculados ({user?.organizations?.length || 0}) +

+

+ Organizações das quais você já é membro ou proprietário. +

+
+ +
+ + +
{user?.organizations && user.organizations.length > 0 ? ( -
+
{user.organizations.map((org) => { const isActive = org.id === activeOrg?.id return ( - handleSelectOrg(org.id)} + className={`rounded-2xl border p-4 transition-all duration-200 cursor-pointer flex items-center justify-between gap-3 shadow-panel ${ + isActive + ? 'border-brand/60 bg-brand/5 ring-1 ring-brand/30 shadow-md' + : 'border-border-soft bg-surface/90 backdrop-blur-md hover:border-brand/40 hover:shadow-lg' + }`} >
{org.name.slice(0, 2).toUpperCase()}
-

+

{org.name}

{isActive && ( - - ATIVO - + + Ativo + )}
-

- Papel: {org.roles.join(', ')} +

+ Papel:{' '} + + {org.roles.join(', ')} +

- -
+ +
) })}
) : ( - -
+
+
-

- Nenhum workspace encontrado +

+ Você ainda não está vinculado a nenhum workspace

-

- Você ainda não faz parte de nenhuma organização. Crie o seu primeiro workspace ao lado para começar! +

+ Você pode solicitar entrada em uma organização existente ou criar um novo workspace para sua equipe.

- +
+ + +
+
)}
+ )} - {/* Create New Workspace Form (5 cols) */} -
- - -
- + {/* TAB 2: SOLICITAR ENTRADA EM UMA ORGANIZAÇÃO */} + {activeTab === 'join' && ( +
+ {/* Search Box */} +
+
+

+ + Buscar Organização +

+

+ Digite o nome da empresa ou organização para solicitar participação como membro. +

+
+ +
+
+ setSearchQuery(e.target.value)} + placeholder="Ex: BeeTech, Empresa Alpha, Squad..." + className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand transition-all pl-9" + /> +
- - Criar Novo Workspace - - - Você será automaticamente configurado como OWNER. - - - - -
- - setNewOrgName(e.target.value)} - placeholder="Ex: Minha Empresa Corp" - className="w-full px-3.5 py-2.5 bg-surface-muted/60 border border-border-soft rounded-xl text-xs text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent transition-all" - /> + + + + {/* Search Results */} + {hasSearched && ( +
+
+ Resultados da busca: + {searchResults.length} encontrada{searchResults.length === 1 ? '' : 's'}
- - - + {searchResults.length === 0 ? ( +
+ Nenhuma organização encontrada com o termo "{searchQuery}". +
+ ) : ( +
+ {searchResults.map((org) => { + const isAlreadyMember = user?.organizations?.some((o) => o.id === org.id) + const pendingReq = myRequests.find( + (r) => r.organizationId === org.id && r.status === 'PENDING' + ) + const isJoining = joiningOrgId === org.id -
- - Multi-tenancy com isolamento de dados + return ( +
+
+
+ {org.name.slice(0, 2).toUpperCase()} +
+
+

+ {org.name} +

+

ID: #{org.id}

+
+
+ +
+ {isAlreadyMember ? ( + + + Já é Membro + + ) : pendingReq ? ( + + + Pendente + + ) : ( + + )} +
+
+ ) + })} +
+ )} +
+ )} +
+ + {/* My Join Requests Status Section */} +
+
+
+

+ + Minhas Solicitações de Entrada +

+

+ Acompanhe o status dos seus pedidos para ingressar em organizações. +

+
+ +
- + + {myRequests.length === 0 ? ( +
+ Você ainda não enviou nenhuma solicitação de entrada. +
+ ) : ( +
+ {myRequests.map((req, idx) => { + const isPending = req.status === 'PENDING' + const isApproved = req.status === 'APPROVED' + const isRejected = req.status === 'REJECTED' + + return ( +
+
+
+ +
+
+

+ {req.organizationName} +

+

+ Solicitado em:{' '} + {req.createdAt + ? new Date(req.createdAt).toLocaleDateString('pt-BR') + : 'Recentemente'} +

+
+
+ +
+ {isPending && ( + + + Pendente de Aprovação + + )} + + {isApproved && ( +
+ + + Aprovada + + +
+ )} + + {isRejected && ( + + + Recusada pelo Dono + + )} +
+
+ ) + })} +
+ )} +
-
+ )} + + {/* TAB 3: CRIAR NOVO WORKSPACE */} + {activeTab === 'create' && ( +
+
+
+ +
+

+ Criar Novo Workspace +

+

+ Você será automaticamente configurado como PROPRIETÁRIO (OWNER) deste workspace. +

+
+ +
+
+ + setNewOrgName(e.target.value)} + placeholder="Ex: Minha Empresa Corp, Tech Labs..." + className="w-full px-3.5 py-2.5 bg-surface border border-border-soft rounded-xl text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all" + /> +
+ + +
+ +
+ + Multi-tenancy com isolamento seguro de projetos, tarefas e métricas da equipe +
+
+ )}
) } diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 8fb5348..ad9cae0 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Mail, @@ -9,9 +9,15 @@ import { CheckCircle2, Save, Sparkles, + Camera, + Edit2, + AlertCircle, + X, + Check, } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' import { taskService } from '../services/taskService' +import { authService } from '../services/authService' import type { Task } from '../types/task' import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils' import { CustomSelect } from '../components/ui/Select' @@ -23,17 +29,101 @@ import { } from '../lib/userGoals' export const ProfilePage: React.FC = () => { - const { user, activeOrg, setActiveOrg, logout } = useAuth() + const { user, activeOrg, setActiveOrg, logout, updateUser, refreshUser } = useAuth() const navigate = useNavigate() const [myTasks, setMyTasks] = useState([]) const [loading, setLoading] = useState(true) + // Photo upload state + const fileInputRef = useRef(null) + const [uploadingPhoto, setUploadingPhoto] = useState(false) + const [photoSuccess, setPhotoSuccess] = useState(false) + const [photoError, setPhotoError] = useState(null) + + // Name edit state + const [editingName, setEditingName] = useState(false) + const [nameInput, setNameInput] = useState(user?.name || '') + const [savingName, setSavingName] = useState(false) + const [nameSuccess, setNameSuccess] = useState(false) + const [nameError, setNameError] = useState(null) + + useEffect(() => { + if (user?.name) { + setNameInput(user.name) + } + }, [user?.name]) + // Workload & Goal State (Global) const [goalPeriod, setGoalPeriod] = useState('DAILY') const [goalHours, setGoalHours] = useState(8) const [savedSuccess, setSavedSuccess] = useState(false) + const handlePhotoSelected = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + // Validate size (5 MB) + if (file.size > 5 * 1024 * 1024) { + setPhotoError('A imagem deve ter no máximo 5 MB.') + setTimeout(() => setPhotoError(null), 5000) + if (fileInputRef.current) fileInputRef.current.value = '' + return + } + + // Validate image mime type + if (!file.type.startsWith('image/')) { + setPhotoError('O arquivo selecionado deve ser uma imagem (JPG, PNG, WebP).') + setTimeout(() => setPhotoError(null), 5000) + if (fileInputRef.current) fileInputRef.current.value = '' + return + } + + setUploadingPhoto(true) + setPhotoError(null) + + try { + const res = await authService.uploadPhoto(file) + // Append timestamp query parameter to bust browser cache + const freshPhotoUrl = res.photoUrl.includes('?') + ? `${res.photoUrl}&t=${Date.now()}` + : `${res.photoUrl}?t=${Date.now()}` + updateUser({ photo: freshPhotoUrl }) + setPhotoSuccess(true) + setTimeout(() => setPhotoSuccess(false), 4000) + await refreshUser() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Falha ao atualizar a foto de perfil.' + setPhotoError(msg) + setTimeout(() => setPhotoError(null), 5000) + } finally { + setUploadingPhoto(false) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + const handleSaveName = async (e: React.FormEvent) => { + e.preventDefault() + if (!user?.email || !nameInput.trim()) return + + setSavingName(true) + setNameError(null) + + try { + const res = await authService.updateProfile(nameInput.trim(), user.email) + updateUser({ name: res.name }) + setNameSuccess(true) + setEditingName(false) + setTimeout(() => setNameSuccess(false), 4000) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Erro ao salvar o nome.' + setNameError(msg) + setTimeout(() => setNameError(null), 5000) + } finally { + setSavingName(false) + } + } + useEffect(() => { if (user?.email) { const g = getUserWorkGoal(user.email) @@ -97,35 +187,165 @@ export const ProfilePage: React.FC = () => {
{/* Main Profile Info Card */} -
-
-
-
- {user.photo ? ( - {user.name - ) : user.name ? ( - user.name.charAt(0).toUpperCase() - ) : ( - user.email.charAt(0).toUpperCase() - )} +
+ {/* Status Alerts */} + {photoSuccess && ( +
+ + Foto de perfil atualizada com sucesso! +
+ )} + + {photoError && ( +
+ + {photoError} +
+ )} + + {nameSuccess && ( +
+ + Nome atualizado com sucesso! +
+ )} + + {nameError && ( +
+ + {nameError} +
+ )} + +
+
+ {/* Avatar with Camera Trigger */} +
+
!uploadingPhoto && fileInputRef.current?.click()} + className={`w-20 h-20 sm:w-24 sm:h-24 rounded-full overflow-hidden border-2 border-brand/50 bg-gradient-to-br from-brand to-teal-700 flex items-center justify-center text-white font-extrabold text-2xl sm:text-3xl shadow-lg shadow-brand/20 cursor-pointer relative transition-transform duration-200 group-hover:scale-[1.02] ${ + uploadingPhoto ? 'opacity-80 pointer-events-none' : '' + }`} + > + {user.photo ? ( + {user.name + ) : user.name ? ( + user.name.charAt(0).toUpperCase() + ) : ( + user.email.charAt(0).toUpperCase() + )} + + {/* Hover Overlay */} +
+ + Alterar +
+ + {/* Uploading Overlay */} + {uploadingPhoto && ( +
+ + Enviando... +
+ )} +
+ + {/* Camera Badge Button */} + + +
-
-

{user.name || 'Usuário'}

-

+ + {/* User Details & Edit Form */} +

+ {editingName ? ( +
+ setNameInput(e.target.value)} + required + placeholder="Seu nome" + className="px-3 py-1.5 rounded-xl border border-brand bg-surface text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-brand/40" + autoFocus + /> + + +
+ ) : ( +
+

{user.name || 'Usuário'}

+ +
+ )} + +

{user.email}

+ +
+ + + PNG, JPG, WebP ou GIF (máx. 5MB) + +