diff --git a/AUTHORS b/AUTHORS index a4635734..bf7587f0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -10,3 +10,4 @@ Ahmed Abbas Ahmad Bustany Omar Goher Rawan Essam +Huda Amr diff --git a/doc/manual/en/26-availability.md b/doc/manual/en/26-availability.md new file mode 100644 index 00000000..076aee68 --- /dev/null +++ b/doc/manual/en/26-availability.md @@ -0,0 +1,109 @@ +\newpage + +# Availability + +The **Availability** feature lets users indicate when they are unavailable, such as when they are out of the office. This information helps support teams understand when a reply should not be expected and provides additional context when working with tickets. + + +## Purpose + +Availability provides a clear way to record periods when a user or an entire company is unavailable. + +This makes it possible for billetsys to represent absence and company-wide closures without relying only on ticket status or manual communication. + +## Personal availability + +Users can create personal availability periods to indicate when they are not available. + +A personal availability entry includes: + +* Start date +* End date +* Reason + +The start and end dates define the period during which the user is unavailable. + +## Creating availability + +To create a personal availability period, open the **Availability** page and add a new entry. + +Select the start and end dates and optionally provide a reason for the absence. + +The reason helps other users understand the context of the unavailable period. + +## Updating and deleting availability + +Users can update or delete their own availability entries. + +Updating an entry changes the dates or reason associated with that period. + +Deleting an entry removes the personal availability period from the calendar and from ticket-related availability information. + +## Company-wide availability + +Company-wide availability is used for periods when an entire company is unavailable. + +This can be useful for: + +* Company holidays +* Office closures +* Organization-wide breaks +* Other periods when the company should not be expected to respond + +Company-wide availability is associated with the company rather than an individual user. + +## Managing company availability + +Company-wide availability can only be managed by users with the required permissions. + +Support and Superuser users can manage company-wide availability for their own company, while users without the required permissions cannot create or modify these entries. + +Company isolation is enforced so that company availability cannot be managed across different companies. + +## Viewing other users' availability + +Availability visibility depends on the user's role. + +Regular users and TAMs can see their own availability together with company-wide availability. + +Support and Superuser users can also view the availability of any other user. + +On the Availability page, Support and Superuser users can search for a user and view that user's availability periods. When viewing another user's availability, the entries are shown read-only. + +Users cannot manage another user's availability. + +## Availability in tickets + +Availability is also used as context when working with tickets. + +When a ticket is associated with an unavailable user or an unavailable company, billetsys can display an availability warning to help support teams understand that a response may be delayed. + +The warning provides additional context without changing the ticket itself. + +## Date rules + +Availability uses calendar dates rather than specific times. + +The end date must not be before the start date. + +A single-day availability period is valid when the start date and end date are the same. + +Personal availability periods cannot overlap with another personal availability period for the same user. + +## Reason + +The reason is optional and can be used to explain why the user or company is unavailable. + +The reason is limited to **255 characters**. + +## Operational value + +Availability gives support teams additional context when deciding how to handle tickets and when a response should be expected. + +Together with ticket information, it helps make periods of absence and company-wide closures visible without requiring users to communicate the same information manually. + +## Keyboard shortcuts + +Like other list and form views in the application, the Availability page supports the application's standard keyboard navigation shortcuts. + +See the **Navigation** chapter for more details. diff --git a/doc/manual/en/97-acknowledgement.md b/doc/manual/en/97-acknowledgement.md index 83030ee4..44109f29 100644 --- a/doc/manual/en/97-acknowledgement.md +++ b/doc/manual/en/97-acknowledgement.md @@ -17,7 +17,7 @@ Ahmed Abbas Ahmad Bustany Omar Goher Rawan Essam - +Huda Amr ``` ## Committers diff --git a/src/backend/main/java/ai/mnemosyne_systems/model/UserAvailability.java b/src/backend/main/java/ai/mnemosyne_systems/model/UserAvailability.java new file mode 100644 index 00000000..d420568c --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/model/UserAvailability.java @@ -0,0 +1,55 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne_systems.model; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; + +import java.time.LocalDate; + +@Entity +@Table(name = "user_availability") +public class UserAvailability extends PanacheEntityBase { + + public static final String SCOPE_PERSONAL = "PERSONAL"; + public static final String SCOPE_COMPANY = "COMPANY"; + + @Id + @SequenceGenerator(name = "user_availability_seq", sequenceName = "user_availability_seq", allocationSize = 1) + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_availability_seq") + public Long id; + + @ManyToOne + @JoinColumn(name = "user_id") + public User user; + + @ManyToOne + @JoinColumn(name = "company_id") + public Company company; + + @Column(name = "start_date", nullable = false) + public LocalDate startDate; + + @Column(name = "end_date", nullable = false) + public LocalDate endDate; + + @Column(nullable = false) + public String scope; + + @Column + public String reason; +} diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/AppSessionResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/AppSessionResource.java index 75c76af3..1abe815c 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/AppSessionResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/AppSessionResource.java @@ -106,21 +106,22 @@ private List navigation(User user) { } if (AuthHelper.isSupport(user)) { return List.of(new NavLink("Tickets", "/support/tickets"), new NavLink("Articles", "/articles"), - new NavLink("Users", "/support/users")); + new NavLink("Users", "/support/users"), new NavLink("Availability", "/availability")); } if (user != null && User.TYPE_USER.equalsIgnoreCase(user.type)) { return List.of(new NavLink("Tickets", "/user/tickets"), new NavLink("Articles", "/articles"), - new NavLink("External", "/user/externals"), new NavLink("Profile", "/profile")); + new NavLink("External", "/user/externals"), new NavLink("Availability", "/availability"), + new NavLink("Profile", "/profile")); } if (AuthHelper.isSuperuser(user)) { return List.of(new NavLink("Tickets", "/superuser/tickets"), new NavLink("Users", "/superuser/users"), new NavLink("Reports", "/reports"), new NavLink("Articles", "/articles"), - new NavLink("Profile", "/profile")); + new NavLink("Availability", "/availability"), new NavLink("Profile", "/profile")); } if (AuthHelper.isTam(user)) { return List.of(new NavLink("Tickets", "/user/tickets"), new NavLink("Reports", "/reports"), new NavLink("Articles", "/articles"), new NavLink("Users", "/tam/users"), - new NavLink("Profile", "/profile")); + new NavLink("Availability", "/availability"), new NavLink("Profile", "/profile")); } return List.of(new NavLink("Tickets", "/user/tickets"), new NavLink("Articles", "/articles"), new NavLink("Profile", "/profile")); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java index 9fc6a9c8..1af4a6ea 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java @@ -21,6 +21,8 @@ import ai.mnemosyne_systems.service.DirectoryService; import ai.mnemosyne_systems.service.EventService; import ai.mnemosyne_systems.service.TicketBootstrapService; +import ai.mnemosyne_systems.service.UserAvailabilityService; +import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.CurrentUser; import jakarta.annotation.security.RolesAllowed; @@ -64,6 +66,9 @@ public class SuperuserTicketApiResource { @Inject DirectoryService directoryService; + @Inject + UserAvailabilityService userAvailabilityService; + @GET @Transactional public SupportTicketApiResource.SupportTicketListResponse list( @@ -205,6 +210,7 @@ public UserTicketApiResource.RoleTicketDetailResponse detail(@PathParam("id") Lo java.util.Map ticketCache = crossReferenceService .preloadReferencedTickets(messages.stream().map(m -> m.body).toList()); List rawEvents = eventService.getAllChangesToEntity(ticket.id); + List availabilityWarnings = userAvailabilityService.getWarningsForTicket(ticket); List eventEntries = rawEvents.stream().map(this::toEventEntry).toList(); return new UserTicketApiResource.RoleTicketDetailResponse(ticket.id, ticket.name, ticket.displayTitle(), @@ -231,7 +237,7 @@ public UserTicketApiResource.RoleTicketDetailResponse detail(@PathParam("id") Lo "/superuser/tickets/" + ticket.id + "/messages", "/tickets/export/" + ticket.id, List.of("Open", "Assigned", "In Progress", "Resolved", "Closed"), false, false, false, true, true, externalUsers.stream().map(this::toUserReference).toList(), - userUsers.stream().map(this::toUserReference).toList(), eventEntries); + userUsers.stream().map(this::toUserReference).toList(), eventEntries, availabilityWarnings); } @GET diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java index fe310a37..6cd10fe4 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java @@ -21,6 +21,7 @@ import ai.mnemosyne_systems.model.event.Event; import ai.mnemosyne_systems.service.EventService; import ai.mnemosyne_systems.service.TicketBootstrapService; +import ai.mnemosyne_systems.service.UserAvailabilityService; import ai.mnemosyne_systems.util.CurrentUser; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; @@ -60,6 +61,9 @@ public class SupportTicketApiResource { @Inject TicketBootstrapService ticketBootstrapService; + @Inject + UserAvailabilityService userAvailabilityService; + @GET @Transactional public SupportTicketListResponse list(@QueryParam("view") @DefaultValue("assigned") String view, @@ -188,6 +192,7 @@ public SupportTicketDetailResponse detail(@PathParam("id") Long id) { List messageEntries = messages.stream().map(m -> toMessageEntry(m, ticketCache)).toList(); List rawEvents = eventService.getAllChangesToEntity(ticket.id); List eventEntries = rawEvents.stream().map(this::toEventEntry).toList(); + List availabilityWarnings = userAvailabilityService.getWarningsForTicket(ticket); return new SupportTicketDetailResponse(ticket.id, ticket.name, ticket.displayTitle(), displayStatus, counts.assignedCount(), counts.openCount(), ticket.company == null ? null : ticket.company.id, ticket.company == null ? null : ticket.company.name, @@ -210,7 +215,7 @@ public SupportTicketDetailResponse detail(@PathParam("id") Long id) { SupportTicketViewSupport.isEntitlementExpired(ticket), "/support/tickets/" + ticket.id, "/support/tickets/" + ticket.id + "/messages", "/tickets/export/" + ticket.id, List.of("Open", "Assigned", "In Progress", "Waiting for External Feedback", "Resolved", "Closed"), - externalUsers.stream().map(this::toUserReference).toList(), eventEntries); + externalUsers.stream().map(this::toUserReference).toList(), eventEntries, availabilityWarnings); } @GET @@ -447,6 +452,6 @@ public record SupportTicketDetailResponse(Long id, String name, String title, St List versions, List categories, List supportUsers, List tamUsers, List messages, boolean ticketEntitlementExpired, String actionPath, String messageActionPath, String exportPath, List statusOptions, - List externalUsers, List events) { + List externalUsers, List events, List availabilityWarnings) { } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/UserAvailabilityApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/UserAvailabilityApiResource.java new file mode 100644 index 00000000..c342dfb5 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/UserAvailabilityApiResource.java @@ -0,0 +1,139 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne_systems.resource; + +import ai.mnemosyne_systems.model.Company; +import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.model.UserAvailability; +import ai.mnemosyne_systems.service.UserAvailabilityService; +import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; +import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +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 java.time.LocalDate; + +@Path("/api/availability") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Blocking +@RolesAllowed({ "admin", "support", "superuser", "tam", "user" }) +public class UserAvailabilityApiResource { + + @Inject + CurrentUser currentUser; + + @Inject + UserAvailabilityService service; + + @GET + @Path("/me") + public Response getMyAvailability() { + User user = currentUser.get(); + return Response.ok(service.getForUser(user).stream().map(this::toResponse).toList()).build(); + } + + @POST + public Response createPersonal(AvailabilityRequest request) { + User user = currentUser.get(); + Company company = Company.find("select c from Company c join c.users u where u = ?1", user).firstResult(); + if (company == null) { + return Response.status(Response.Status.BAD_REQUEST).entity("User does not belong to a company").build(); + } + UserAvailability availability = service.createPersonal(user, company, request.startDate, request.endDate, + request.reason); + return Response.status(Response.Status.CREATED).entity(toResponse(availability)).build(); + } + + @GET + @Path("/user/{userId}") + public Response getUserAvailability(@PathParam("userId") Long userId) { + User actor = currentUser.get(); + User targetUser = User.findById(userId); + if (targetUser == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + + // Support and superusers can view availability for any user, matching + // the user directories they already have access to. Everyone else can + // only view their own availability. + boolean isOwnAvailability = actor.id.equals(targetUser.id); + if (!isOwnAvailability && !AuthHelper.isSupport(actor) && !AuthHelper.isSuperuser(actor)) { + return Response.status(Response.Status.FORBIDDEN).build(); + } + + return Response.ok(service.getForUser(targetUser).stream().map(this::toResponse).toList()).build(); + } + + @PUT + @Path("/{id}") + public Response update(@PathParam("id") Long id, AvailabilityRequest request) { + User user = currentUser.get(); + UserAvailability availability = service.update(user, id, request.startDate, request.endDate, request.reason); + return Response.ok(toResponse(availability)).build(); + } + + @DELETE + @Path("/{id}") + public Response delete(@PathParam("id") Long id) { + User user = currentUser.get(); + service.delete(user, id); + return Response.noContent().build(); + } + + @GET + @Path("/company") + public Response getCompanyAvailability() { + User user = currentUser.get(); + Company company = Company.find("select c from Company c join c.users u where u = ?1", user).firstResult(); + if (company == null) { + return Response.status(Response.Status.BAD_REQUEST).entity("User does not belong to a company").build(); + } + return Response.ok(service.getForCompany(company).stream().map(this::toResponse).toList()).build(); + } + + @POST + @Path("/company") + public Response createCompany(AvailabilityRequest request) { + User user = currentUser.get(); + Company company = Company.find("select c from Company c join c.users u where u = ?1", user).firstResult(); + if (company == null) { + return Response.status(Response.Status.BAD_REQUEST).entity("User does not belong to a company").build(); + } + UserAvailability availability = service.createCompany(user, company, request.startDate, request.endDate, + request.reason); + return Response.status(Response.Status.CREATED).entity(toResponse(availability)).build(); + } + + public static class AvailabilityRequest { + + public LocalDate startDate; + public LocalDate endDate; + public String reason; + } + + private AvailabilityResponse toResponse(UserAvailability availability) { + return new AvailabilityResponse(availability.id, availability.scope, availability.startDate, + availability.endDate, availability.reason); + } + + public record AvailabilityResponse(Long id, String scope, LocalDate startDate, LocalDate endDate, String reason) { + } +} diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java index a0340d13..d5a3909e 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java @@ -21,6 +21,7 @@ import ai.mnemosyne_systems.service.DirectoryService; import ai.mnemosyne_systems.service.EventService; import ai.mnemosyne_systems.service.TicketBootstrapService; +import ai.mnemosyne_systems.service.UserAvailabilityService; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.CurrentUser; import jakarta.annotation.security.RolesAllowed; @@ -65,6 +66,9 @@ public class UserTicketApiResource { @Inject DirectoryService directoryService; + @Inject + UserAvailabilityService userAvailabilityService; + @GET @Transactional public SupportTicketApiResource.SupportTicketListResponse list( @@ -209,6 +213,7 @@ public RoleTicketDetailResponse detail(@PathParam("id") Long id) { .preloadReferencedTickets(messages.stream().map(m -> m.body).toList()); List rawEvents = eventService.getAllChangesToEntity(ticket.id); List eventEntries = rawEvents.stream().map(this::toEventEntry).toList(); + List availabilityWarnings = userAvailabilityService.getWarningsForTicket(ticket); return new RoleTicketDetailResponse(ticket.id, ticket.name, ticket.displayTitle(), normalizeDisplayStatus(ticket.status), data.assignedTickets == null ? 0 : data.assignedTickets.size(), data.openTickets == null ? 0 : data.openTickets.size(), @@ -233,7 +238,7 @@ public RoleTicketDetailResponse detail(@PathParam("id") Long id) { "/user/tickets/" + ticket.id + "/messages", "/tickets/export/" + ticket.id, List.of("Open", "Assigned", "In Progress", "Resolved", "Closed"), false, false, false, true, tamView, externalUsers.stream().map(this::toUserReference).toList(), - userUsers.stream().map(this::toUserReference).toList(), eventEntries); + userUsers.stream().map(this::toUserReference).toList(), eventEntries, availabilityWarnings); } @GET @@ -381,6 +386,7 @@ public record RoleTicketDetailResponse(Long id, String name, String title, Strin String messageActionPath, String exportPath, List statusOptions, boolean editableStatus, boolean editableCategory, boolean editableExternalIssue, boolean editableAffectsVersion, boolean editableResolvedVersion, List externalUsers, - List userUsers, List events) { + List userUsers, List events, + List availabilityWarnings) { } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/service/UserAvailabilityService.java b/src/backend/main/java/ai/mnemosyne_systems/service/UserAvailabilityService.java new file mode 100644 index 00000000..087f805d --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/service/UserAvailabilityService.java @@ -0,0 +1,455 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THE ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THE AGREEMENT. + */ + +package ai.mnemosyne_systems.service; + +import ai.mnemosyne_systems.model.Company; +import ai.mnemosyne_systems.model.Ticket; +import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.model.UserAvailability; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.NotAuthorizedException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.NotFoundException; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@ApplicationScoped +public class UserAvailabilityService { + + @Transactional + public UserAvailability createPersonal(User user, Company company, LocalDate startDate, LocalDate endDate, + String reason) { + + validateDates(startDate, endDate); + + if (user == null) { + throw new IllegalArgumentException("User is required"); + } + + if (company == null) { + throw new IllegalArgumentException("Company is required"); + } + + validatePersonalPermission(user); + + validateCompanyConsistency(user, company); + + validateNoOverlappingPersonalAvailability(user, startDate, endDate, null); + + UserAvailability availability = new UserAvailability(); + + availability.user = user; + availability.company = company; + availability.startDate = startDate; + availability.endDate = endDate; + availability.scope = UserAvailability.SCOPE_PERSONAL; + availability.reason = trimOrNull(reason); + + availability.persist(); + + return availability; + } + + @Transactional + public UserAvailability createCompany(User user, Company company, LocalDate startDate, LocalDate endDate, + String reason) { + + validateDates(startDate, endDate); + + if (user == null) { + throw new IllegalArgumentException("User is required"); + } + + if (company == null) { + throw new IllegalArgumentException("Company is required"); + } + + validateCompanyAvailabilityPermission(user); + + validateCompanyConsistency(user, company); + + UserAvailability availability = new UserAvailability(); + + availability.user = null; + availability.company = company; + availability.startDate = startDate; + availability.endDate = endDate; + availability.scope = UserAvailability.SCOPE_COMPANY; + availability.reason = trimOrNull(reason); + + availability.persist(); + + return availability; + } + + @Transactional + public UserAvailability update(User user, Long id, LocalDate startDate, LocalDate endDate, String reason) { + + validateDates(startDate, endDate); + + if (user == null) { + throw new IllegalArgumentException("User is required"); + } + + if (id == null) { + throw new BadRequestException("Availability id is required"); + } + + UserAvailability availability = UserAvailability.findById(id); + + if (availability == null) { + throw new NotFoundException(); + } + + validateUpdatePermission(user, availability); + + if (UserAvailability.SCOPE_PERSONAL.equals(availability.scope)) { + + validateNoOverlappingPersonalAvailability(availability.user, startDate, endDate, availability.id); + } + + availability.startDate = startDate; + availability.endDate = endDate; + availability.reason = trimOrNull(reason); + + return availability; + } + + @Transactional + public void delete(User user, Long id) { + + if (user == null) { + throw new IllegalArgumentException("User is required"); + } + + if (id == null) { + throw new BadRequestException("Availability id is required"); + } + + UserAvailability availability = UserAvailability.findById(id); + + if (availability == null) { + throw new NotFoundException(); + } + validateUpdatePermission(user, availability); + + availability.delete(); + } + + public List getForUser(User user) { + + if (user == null) { + throw new IllegalArgumentException("User is required"); + } + + return UserAvailability + .find("user = ?1 and scope = ?2 " + "order by startDate asc", user, UserAvailability.SCOPE_PERSONAL) + .list(); + } + + public List getForCompany(Company company) { + + if (company == null) { + throw new IllegalArgumentException("Company is required"); + } + + return UserAvailability.find("company = ?1 and scope = ?2 " + "order by startDate asc", company, + UserAvailability.SCOPE_COMPANY).list(); + } + + public List getWarningsForTicket(Ticket ticket) { + + if (ticket == null) { + throw new IllegalArgumentException("Ticket is required"); + } + + List warnings = new ArrayList<>(); + + if (ticket.company == null) { + return warnings; + } + + LocalDate today = LocalDate.now(); + + List companyAvailability = UserAvailability + .find("company = ?1 " + "and scope = ?2 " + "and startDate <= ?3 " + "and endDate >= ?3", + ticket.company, UserAvailability.SCOPE_COMPANY, today) + .list(); + + for (UserAvailability availability : companyAvailability) { + + String message = buildCompanyWarning(availability); + + if (message != null) { + warnings.add(message); + } + } + + Set checkedUserIds = new HashSet<>(); + + for (User user : ticket.supportUsers) { + + if (user == null || user.id == null) { + continue; + } + + if (!checkedUserIds.add(user.id)) { + continue; + } + + addUserWarning(warnings, user, ticket.company, today); + } + + for (User user : ticket.tamUsers) { + + if (user == null || user.id == null) { + continue; + } + + if (!checkedUserIds.add(user.id)) { + continue; + } + + addUserWarning(warnings, user, ticket.company, today); + } + + for (User user : ticket.userUsers) { + + if (user == null || user.id == null) { + continue; + } + + if (!checkedUserIds.add(user.id)) { + continue; + } + + addUserWarning(warnings, user, ticket.company, today); + } + + return warnings; + } + + private void addUserWarning(List warnings, User user, Company company, LocalDate date) { + + List personalAvailability = UserAvailability + .find("user = ?1 " + "and scope = ?2 " + "and startDate <= ?3 " + "and endDate >= ?3", user, + UserAvailability.SCOPE_PERSONAL, date) + .list(); + + for (UserAvailability availability : personalAvailability) { + + String message = buildUserWarning(availability); + + if (message != null) { + warnings.add(message); + } + } + } + + private String buildUserWarning(UserAvailability availability) { + + if (availability == null || availability.user == null) { + return null; + } + + StringBuilder warning = new StringBuilder(); + + warning.append("User ").append(displayUser(availability.user)).append(" is out of office from ") + .append(availability.startDate).append(" to ").append(availability.endDate); + + if (availability.reason != null && !availability.reason.isBlank()) { + + warning.append(" (").append(availability.reason).append(")"); + } + + return warning.toString(); + } + + private String buildCompanyWarning(UserAvailability availability) { + + if (availability == null || availability.company == null) { + return null; + } + + StringBuilder warning = new StringBuilder(); + + warning.append("Company ").append(availability.company.name).append(" is out of office from ") + .append(availability.startDate).append(" to ").append(availability.endDate); + + if (availability.reason != null && !availability.reason.isBlank()) { + + warning.append(" (").append(availability.reason).append(")"); + } + + return warning.toString(); + } + + private void validatePersonalPermission(User user) { + + String type = normalize(user.type); + + if (!User.TYPE_USER.equals(type) && !User.TYPE_TAM.equals(type) && !User.TYPE_SUPPORT.equals(type) + && !User.TYPE_SUPERUSER.equals(type) && !User.TYPE_ADMIN.equals(type)) { + + throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); + } + } + + private void validateCompanyAvailabilityPermission(User user) { + + String type = normalize(user.type); + + if (!User.TYPE_SUPPORT.equals(type) && !User.TYPE_SUPERUSER.equals(type)) { + + throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); + } + } + + private void validateUpdatePermission(User user, UserAvailability availability) { + + String type = normalize(user.type); + + if (UserAvailability.SCOPE_COMPANY.equals(availability.scope)) { + + validateCompanyAvailabilityPermission(user); + + validateCompanyConsistency(user, availability.company); + + return; + } + + if (!UserAvailability.SCOPE_PERSONAL.equals(availability.scope)) { + + throw new IllegalStateException("Invalid availability scope"); + } + + if (availability.user == null || user.id == null || !user.id.equals(availability.user.id)) { + + throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); + } + + validatePersonalPermission(user); + + validateCompanyConsistency(user, availability.company); + } + + private void validateCompanyConsistency(User user, Company company) { + + if (user == null) { + throw new IllegalArgumentException("User is required"); + } + + if (company == null) { + throw new IllegalArgumentException("Company is required"); + } + + boolean belongsToCompany = Company + .count("select count(c) from Company c join c.users u where c = ?1 and u = ?2", company, user) > 0; + + if (!belongsToCompany) { + throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); + } + } + + private void validateNoOverlappingPersonalAvailability(User user, LocalDate startDate, LocalDate endDate, + Long excludedId) { + + if (user == null) { + throw new IllegalArgumentException("User is required"); + } + + boolean overlaps; + + if (excludedId == null) { + + overlaps = UserAvailability.count( + "user = ?1 " + "and scope = ?2 " + "and startDate <= ?3 " + "and endDate >= ?4", user, + UserAvailability.SCOPE_PERSONAL, endDate, startDate) > 0; + + } else { + + overlaps = UserAvailability.count( + "user = ?1 " + "and scope = ?2 " + "and id <> ?3 " + "and startDate <= ?4 " + "and endDate >= ?5", + user, UserAvailability.SCOPE_PERSONAL, excludedId, endDate, startDate) > 0; + } + + if (overlaps) { + throw new BadRequestException("Personal availability period overlaps with an existing period"); + } + } + + private void validateDates(LocalDate startDate, LocalDate endDate) { + + if (startDate == null) { + throw new BadRequestException("Start date is required"); + } + + if (endDate == null) { + throw new BadRequestException("End date is required"); + } + + if (endDate.isBefore(startDate)) { + throw new BadRequestException("End date cannot be before start date"); + } + } + + private String normalize(String value) { + + if (value == null) { + return ""; + } + + return value.trim().toLowerCase(); + } + + private String trimOrNull(String value) { + + if (value == null) { + return null; + } + + String trimmed = value.trim(); + + if (trimmed.isEmpty()) { + return null; + } + + if (trimmed.length() > 255) { + throw new BadRequestException("Reason must not exceed 255 characters"); + } + + return trimmed; + } + + private String displayUser(User user) { + + if (user.fullName != null && !user.fullName.isBlank()) { + + return user.fullName; + } + + if (user.name != null && !user.name.isBlank()) { + + return user.name; + } + + if (user.email != null && !user.email.isBlank()) { + + return user.email; + } + + return String.valueOf(user.id); + } +} diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/UserAvailabilityApiResourceTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/UserAvailabilityApiResourceTest.java new file mode 100644 index 00000000..40a4128b --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/UserAvailabilityApiResourceTest.java @@ -0,0 +1,1008 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne_systems.resource; + +import ai.mnemosyne_systems.model.Company; +import ai.mnemosyne_systems.model.Ticket; +import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.model.UserAvailability; +import ai.mnemosyne_systems.service.UserAvailabilityService; + +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; +import io.restassured.response.Response; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.hasItem; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +class UserAvailabilityApiResourceTest extends AccessTestSupport { + + private static final String BASE_PATH = "/api/availability"; + + private static final String USER_EMAIL = "avail-user@testing.com"; + private static final String USER2_EMAIL = "avail-user-2@testing.com"; + private static final String USER_B_EMAIL = "avail-user-b@testing.com"; + private static final String NO_COMPANY_EMAIL = "avail-nocompany@testing.com"; + private static final String TAM_EMAIL = "avail-tam@testing.com"; + private static final String TAM2_EMAIL = "avail-tam-2@testing.com"; + private static final String SUPPORT_EMAIL = "avail-support@testing.com"; + private static final String SUPPORT2_EMAIL = "avail-support-2@testing.com"; + private static final String SUPERUSER_EMAIL = "avail-superuser@testing.com"; + private static final String SUPERUSER2_EMAIL = "avail-superuser-2@testing.com"; + + private static final String COMPANY_A = "Availability Company A"; + private static final String COMPANY_B = "Availability Company B"; + + @Inject + UserAvailabilityService service; + + private final List createdTicketIds = new ArrayList<>(); + + @AfterEach + void cleanup() { + QuarkusTransaction.requiringNew().run(() -> { + for (Long id : createdTicketIds) { + Ticket ticket = Ticket.findById(id); + if (ticket != null) { + ticket.delete(); + } + } + List emails = List.of(USER_EMAIL, USER2_EMAIL, USER_B_EMAIL, NO_COMPANY_EMAIL, TAM_EMAIL, + TAM2_EMAIL, SUPPORT_EMAIL, SUPPORT2_EMAIL, SUPERUSER_EMAIL, SUPERUSER2_EMAIL); + for (String email : emails) { + User user = User.find("email", email).firstResult(); + if (user != null) { + UserAvailability.delete("user", user); + } + } + for (String companyName : List.of(COMPANY_A, COMPANY_B)) { + Company company = Company.find("name", companyName).firstResult(); + if (company != null) { + UserAvailability.delete("company", company); + } + } + }); + createdTicketIds.clear(); + } + + private void seedUsers() { + ensureUser("avail-user", USER_EMAIL, User.TYPE_USER); + ensureUser("avail-user-2", USER2_EMAIL, User.TYPE_USER); + ensureUser("avail-user-b", USER_B_EMAIL, User.TYPE_USER); + ensureUser("avail-nocompany", NO_COMPANY_EMAIL, User.TYPE_USER); + ensureUser("avail-tam", TAM_EMAIL, User.TYPE_TAM); + ensureUser("avail-tam-2", TAM2_EMAIL, User.TYPE_TAM); + ensureUser("avail-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + ensureUser("avail-support-2", SUPPORT2_EMAIL, User.TYPE_SUPPORT); + ensureUser("avail-superuser", SUPERUSER_EMAIL, User.TYPE_SUPERUSER); + ensureUser("avail-superuser-2", SUPERUSER2_EMAIL, User.TYPE_SUPERUSER); + } + + private Company companyA() { + Long companyId = ensureCompany(COMPANY_A); + ensureCompanyUsers(companyId, USER_EMAIL, USER2_EMAIL, TAM_EMAIL, TAM2_EMAIL, SUPPORT_EMAIL, SUPERUSER_EMAIL); + return Company.findById(companyId); + } + + private Company companyB() { + Long companyId = ensureCompany(COMPANY_B); + ensureCompanyUsers(companyId, USER_B_EMAIL, SUPPORT2_EMAIL, SUPERUSER2_EMAIL); + return Company.findById(companyId); + } + + private UserAvailability createPersonalAvailabilityDirect(User user, Company company, LocalDate start, + LocalDate end, String reason) { + + UserAvailability[] holder = new UserAvailability[1]; + QuarkusTransaction.requiringNew().run(() -> holder[0] = service.createPersonal(User.findById(user.id), + Company.findById(company.id), start, end, reason)); + return holder[0]; + } + + private UserAvailability createCompanyAvailabilityDirect(User user, Company company, LocalDate start, LocalDate end, + String reason) { + + UserAvailability[] holder = new UserAvailability[1]; + QuarkusTransaction.requiringNew().run(() -> holder[0] = service.createCompany(User.findById(user.id), + Company.findById(company.id), start, end, reason)); + return holder[0]; + } + + private Ticket createTicketWithParticipants(Company company, User support, User tam, User regularUser) { + Ticket[] holder = new Ticket[1]; + QuarkusTransaction.requiringNew().run(() -> { + Ticket ticket = new Ticket(); + ticket.name = "TKT-" + UUID.randomUUID(); + ticket.status = "Open"; + ticket.company = Company.findById(company.id); + ticket.supportUsers.add(User.findById(support.id)); + ticket.tamUsers.add(User.findById(tam.id)); + ticket.userUsers.add(User.findById(regularUser.id)); + ticket.persist(); + holder[0] = ticket; + }); + createdTicketIds.add(holder[0].id); + return holder[0]; + } + + private Map requestBody(LocalDate start, LocalDate end, String reason) { + Map body = new HashMap<>(); + body.put("startDate", start == null ? null : start.toString()); + body.put("endDate", end == null ? null : end.toString()); + body.put("reason", reason); + return body; + } + + @Test + void getMyAvailability_withoutAuthentication_returnsUnauthorized() { + given().when().get(BASE_PATH + "/me").then().statusCode(401); + } + + @Test + void createPersonal_withoutAuthentication_returnsUnauthorized() { + given().contentType("application/json").body(requestBody(LocalDate.now(), LocalDate.now().plusDays(1), "PTO")) + .when().post(BASE_PATH).then().statusCode(401); + } + + @Test + @TestSecurity(user = USER2_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER2_EMAIL), @Claim(key = "sub", value = "avail-user-2") }) + void update_byDifferentUser_isRejectedAndLeavesRecordUnchanged() { + seedUsers(); + Company company = companyA(); + User owner = User.find("email", USER_EMAIL).firstResult(); + + LocalDate originalStart = LocalDate.now().plusDays(10); + LocalDate originalEnd = LocalDate.now().plusDays(15); + UserAvailability availability = createPersonalAvailabilityDirect(owner, company, originalStart, originalEnd, + "Vacation"); + + Response response = given().contentType("application/json") + .body(requestBody(originalStart.plusDays(1), originalEnd.plusDays(1), "Hijacked")).when() + .put(BASE_PATH + "/" + availability.id); + + assertEquals(401, response.getStatusCode()); + + UserAvailability reloaded = UserAvailability.findById(availability.id); + assertNotNull(reloaded); + assertEquals(originalStart, reloaded.startDate); + assertEquals(originalEnd, reloaded.endDate); + } + + @Test + @TestSecurity(user = USER2_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER2_EMAIL), @Claim(key = "sub", value = "avail-user-2") }) + void delete_byDifferentUser_isRejectedAndRecordStillExists() { + seedUsers(); + Company company = companyA(); + User owner = User.find("email", USER_EMAIL).firstResult(); + + UserAvailability availability = createPersonalAvailabilityDirect(owner, company, LocalDate.now().plusDays(10), + LocalDate.now().plusDays(15), "Vacation"); + + Response response = given().when().delete(BASE_PATH + "/" + availability.id); + + assertEquals(401, response.getStatusCode()); + assertNotNull(UserAvailability.findById(availability.id)); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createCompanyWide_byRegularUser_isRejected() { + seedUsers(); + Company company = companyA(); + + Response response = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(1), LocalDate.now().plusDays(2), "Company retreat")).when() + .post(BASE_PATH + "/company"); + + assertEquals(401, response.getStatusCode()); + + List companyAvailability = service.getForCompany(company); + assertTrue(companyAvailability.isEmpty(), "No company availability should have been created"); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), + @Claim(key = "sub", value = "avail-support") }) + void createCompanyWide_bySupport_isAllowed() { + seedUsers(); + Company company = companyA(); + + Response response = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(1), LocalDate.now().plusDays(2), "Company retreat")).when() + .post(BASE_PATH + "/company"); + + response.then().statusCode(201); + + List companyAvailability = service.getForCompany(company); + assertEquals(1, companyAvailability.size()); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createPersonal_withValidData_returns201AndPersists() { + seedUsers(); + Company company = companyA(); + User user = User.find("email", USER_EMAIL).firstResult(); + + LocalDate start = LocalDate.now().plusDays(5); + LocalDate end = LocalDate.now().plusDays(8); + + Response response = given().contentType("application/json").body(requestBody(start, end, "Vacation")).when() + .post(BASE_PATH); + + response.then().statusCode(201); + + assertEquals(start.toString(), response.jsonPath().getString("startDate")); + assertEquals(end.toString(), response.jsonPath().getString("endDate")); + + List stored = service.getForUser(user); + assertEquals(1, stored.size()); + assertEquals("Vacation", stored.get(0).reason); + } + + @Test + @TestSecurity(user = NO_COMPANY_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = NO_COMPANY_EMAIL), + @Claim(key = "sub", value = "avail-nocompany") }) + void createPersonal_whenUserHasNoCompany_returns400() { + seedUsers(); + + given().contentType("application/json").body(requestBody(LocalDate.now(), LocalDate.now().plusDays(1), "PTO")) + .when().post(BASE_PATH).then().statusCode(400); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void update_byOwner_changesDates() { + seedUsers(); + Company company = companyA(); + User user = User.find("email", USER_EMAIL).firstResult(); + + UserAvailability availability = createPersonalAvailabilityDirect(user, company, LocalDate.now().plusDays(10), + LocalDate.now().plusDays(15), "Vacation"); + + LocalDate newStart = LocalDate.now().plusDays(12); + LocalDate newEnd = LocalDate.now().plusDays(18); + + given().contentType("application/json").body(requestBody(newStart, newEnd, "Extended vacation")).when() + .put(BASE_PATH + "/" + availability.id).then().statusCode(200); + + UserAvailability reloaded = UserAvailability.findById(availability.id); + assertEquals(newStart, reloaded.startDate); + assertEquals(newEnd, reloaded.endDate); + assertEquals("Extended vacation", reloaded.reason); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void delete_byOwner_returns204AndRemovesRecord() { + seedUsers(); + Company company = companyA(); + User user = User.find("email", USER_EMAIL).firstResult(); + + UserAvailability availability = createPersonalAvailabilityDirect(user, company, LocalDate.now().plusDays(10), + LocalDate.now().plusDays(15), "Vacation"); + + given().when().delete(BASE_PATH + "/" + availability.id).then().statusCode(204); + + assertEquals(null, UserAvailability.findById(availability.id)); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createPersonal_overlappingExistingRange_isRejected() { + seedUsers(); + Company company = companyA(); + User user = User.find("email", USER_EMAIL).firstResult(); + + LocalDate existingStart = LocalDate.now().plusDays(10); + LocalDate existingEnd = LocalDate.now().plusDays(15); + createPersonalAvailabilityDirect(user, company, existingStart, existingEnd, "Vacation"); + + Response response = given().contentType("application/json") + .body(requestBody(existingStart.plusDays(3), existingEnd.plusDays(3), "Second trip")).when() + .post(BASE_PATH); + + assertEquals(400, response.getStatusCode()); + + List stored = service.getForUser(user); + assertEquals(1, stored.size(), "Only the original availability should exist"); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createPersonal_nonOverlappingRanges_bothSucceed() { + seedUsers(); + Company company = companyA(); + User user = User.find("email", USER_EMAIL).firstResult(); + + LocalDate existingStart = LocalDate.now().plusDays(10); + LocalDate existingEnd = LocalDate.now().plusDays(15); + createPersonalAvailabilityDirect(user, company, existingStart, existingEnd, "Vacation"); + + given().contentType("application/json") + .body(requestBody(existingStart.minusDays(9), existingStart.minusDays(1), "Earlier trip")).when() + .post(BASE_PATH).then().statusCode(201); + + given().contentType("application/json") + .body(requestBody(existingEnd.plusDays(1), existingEnd.plusDays(5), "Later trip")).when() + .post(BASE_PATH).then().statusCode(201); + + List stored = service.getForUser(user); + assertEquals(3, stored.size()); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void updatePersonal_toOverlapAnotherRange_isRejected() { + seedUsers(); + Company company = companyA(); + User user = User.find("email", USER_EMAIL).firstResult(); + + UserAvailability first = createPersonalAvailabilityDirect(user, company, LocalDate.now().plusDays(1), + LocalDate.now().plusDays(5), "First trip"); + createPersonalAvailabilityDirect(user, company, LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + "Second trip"); + + Response response = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(1), LocalDate.now().plusDays(12), "Stretched")).when() + .put(BASE_PATH + "/" + first.id); + + assertEquals(400, response.getStatusCode()); + + UserAvailability reloaded = UserAvailability.findById(first.id); + assertEquals(LocalDate.now().plusDays(5), reloaded.endDate, "Original range must remain unchanged"); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createPersonal_endDateBeforeStartDate_isRejected() { + seedUsers(); + Company company = companyA(); + User user = User.find("email", USER_EMAIL).firstResult(); + + Response response = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(10), LocalDate.now().plusDays(5), "Invalid")).when() + .post(BASE_PATH); + + assertEquals(400, response.getStatusCode()); + + assertTrue(service.getForUser(user).isEmpty()); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createPersonal_missingStartDate_isRejected() { + seedUsers(); + companyA(); + + Response response = given().contentType("application/json") + .body(requestBody(null, LocalDate.now().plusDays(5), "Missing start")).when().post(BASE_PATH); + + assertEquals(400, response.getStatusCode()); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createPersonal_missingEndDate_isRejected() { + seedUsers(); + companyA(); + + Response response = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(5), null, "Missing end")).when().post(BASE_PATH); + + assertEquals(400, response.getStatusCode()); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createPersonal_reasonWith254Characters_returns201() { + seedUsers(); + companyA(); + + String reason = "a".repeat(254); + + Response response = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(5), LocalDate.now().plusDays(6), reason)).when() + .post(BASE_PATH); + + assertEquals(201, response.getStatusCode()); + assertEquals(reason, response.jsonPath().getString("reason")); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createPersonal_reasonWith255Characters_returns201() { + seedUsers(); + companyA(); + + String reason = "a".repeat(255); + + Response response = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(5), LocalDate.now().plusDays(6), reason)).when() + .post(BASE_PATH); + + assertEquals(201, response.getStatusCode()); + assertEquals(reason, response.jsonPath().getString("reason")); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createPersonal_reasonExceeding255Characters_returns400() { + seedUsers(); + Company company = companyA(); + User user = User.find("email", USER_EMAIL).firstResult(); + + String reason = "a".repeat(256); + + Response response = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(5), LocalDate.now().plusDays(6), reason)).when() + .post(BASE_PATH); + + assertEquals(400, response.getStatusCode()); + assertTrue(service.getForUser(user).isEmpty()); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void update_nonexistentAvailability_returns404() { + seedUsers(); + companyA(); + + Response response = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(5), LocalDate.now().plusDays(6), "Not found")).when() + .put(BASE_PATH + "/999999999"); + + assertEquals(404, response.getStatusCode()); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void delete_nonexistentAvailability_returns404() { + seedUsers(); + companyA(); + + Response response = given().when().delete(BASE_PATH + "/999999999"); + + assertEquals(404, response.getStatusCode()); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void createPersonal_sameStartAndEndDate_isAllowed() { + seedUsers(); + companyA(); + LocalDate day = LocalDate.now().plusDays(3); + + Response response = given().contentType("application/json").body(requestBody(day, day, "Single day off")).when() + .post(BASE_PATH); + + response.then().statusCode(201); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void user_canGetOwnAvailability_returnsOwnRecordsOnly() { + seedUsers(); + Company company = companyA(); + User owner = User.find("email", USER_EMAIL).firstResult(); + User other = User.find("email", USER2_EMAIL).firstResult(); + + UserAvailability own = createPersonalAvailabilityDirect(owner, company, LocalDate.now().plusDays(10), + LocalDate.now().plusDays(12), "Own"); + createPersonalAvailabilityDirect(other, company, LocalDate.now().plusDays(20), LocalDate.now().plusDays(22), + "Other"); + + Response response = given().when().get(BASE_PATH + "/me"); + + response.then().statusCode(200); + assertEquals(List.of(own.id), response.jsonPath().getList("id", Long.class)); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void user_canGetUserAvailability_forOwnUser() { + seedUsers(); + Company company = companyA(); + User user = User.find("email", USER_EMAIL).firstResult(); + + UserAvailability own = createPersonalAvailabilityDirect(user, company, LocalDate.now().plusDays(10), + LocalDate.now().plusDays(12), "Own"); + + Response response = given().when().get(BASE_PATH + "/user/" + user.id); + + response.then().statusCode(200); + + assertEquals(List.of(own.id), response.jsonPath().getList("id", Long.class)); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void user_cannotGetAnotherUsersAvailability() { + seedUsers(); + companyA(); + User other = User.find("email", USER2_EMAIL).firstResult(); + + createPersonalAvailabilityDirect(other, companyA(), LocalDate.now().plusDays(10), LocalDate.now().plusDays(12), + "Other"); + + given().when().get(BASE_PATH + "/user/" + other.id).then().statusCode(403); + } + + @Test + @TestSecurity(user = TAM_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM_EMAIL), @Claim(key = "sub", value = "avail-tam") }) + void tam_cannotGetAnotherUsersAvailability() { + seedUsers(); + companyA(); + User other = User.find("email", USER_EMAIL).firstResult(); + + createPersonalAvailabilityDirect(other, companyA(), LocalDate.now().plusDays(10), LocalDate.now().plusDays(12), + "Other"); + + given().when().get(BASE_PATH + "/user/" + other.id).then().statusCode(403); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), + @Claim(key = "sub", value = "avail-support") }) + void support_canGetAnotherUsersAvailability() { + seedUsers(); + companyA(); + User other = User.find("email", USER_EMAIL).firstResult(); + + UserAvailability otherAvailability = createPersonalAvailabilityDirect(other, companyA(), + LocalDate.now().plusDays(10), LocalDate.now().plusDays(12), "Other PTO"); + + Response response = given().when().get(BASE_PATH + "/user/" + other.id); + + response.then().statusCode(200); + + assertEquals(List.of(otherAvailability.id), response.jsonPath().getList("id", Long.class)); + } + + @Test + @TestSecurity(user = SUPERUSER_EMAIL, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER_EMAIL), + @Claim(key = "sub", value = "avail-superuser") }) + void superuser_canGetAnotherUsersAvailability() { + seedUsers(); + companyA(); + User other = User.find("email", USER_EMAIL).firstResult(); + + UserAvailability otherAvailability = createPersonalAvailabilityDirect(other, companyA(), + LocalDate.now().plusDays(10), LocalDate.now().plusDays(12), "Other PTO"); + + Response response = given().when().get(BASE_PATH + "/user/" + other.id); + + response.then().statusCode(200); + + assertEquals(List.of(otherAvailability.id), response.jsonPath().getList("id", Long.class)); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void getUserAvailability_forUnknownUser_returnsNotFound() { + seedUsers(); + companyA(); + + given().when().get(BASE_PATH + "/user/999999999").then().statusCode(404); + } + + @Test + void getUserAvailability_withoutAuthentication_returnsUnauthorized() { + given().when().get(BASE_PATH + "/user/1").then().statusCode(401); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), + @Claim(key = "sub", value = "avail-support") }) + void support_canGetAnotherCompanyUsersAvailability() { + seedUsers(); + companyA(); + companyB(); + User other = User.find("email", USER_B_EMAIL).firstResult(); + + UserAvailability otherAvailability = createPersonalAvailabilityDirect(other, companyB(), + LocalDate.now().plusDays(10), LocalDate.now().plusDays(12), "Other PTO"); + + Response response = given().when().get(BASE_PATH + "/user/" + other.id); + + response.then().statusCode(200); + + assertEquals(List.of(otherAvailability.id), response.jsonPath().getList("id", Long.class)); + } + + @Test + @TestSecurity(user = SUPERUSER_EMAIL, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER_EMAIL), + @Claim(key = "sub", value = "avail-superuser") }) + void superuser_canGetAnotherCompanyUsersAvailability() { + seedUsers(); + companyA(); + companyB(); + User other = User.find("email", USER_B_EMAIL).firstResult(); + + UserAvailability otherAvailability = createPersonalAvailabilityDirect(other, companyB(), + LocalDate.now().plusDays(10), LocalDate.now().plusDays(12), "Other PTO"); + + Response response = given().when().get(BASE_PATH + "/user/" + other.id); + + response.then().statusCode(200); + + assertEquals(List.of(otherAvailability.id), response.jsonPath().getList("id", Long.class)); + } + + @Test + @TestSecurity(user = TAM_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM_EMAIL), @Claim(key = "sub", value = "avail-tam") }) + void tam_cannotGetAnotherCompanyUsersAvailability() { + seedUsers(); + companyA(); + companyB(); + User other = User.find("email", USER_B_EMAIL).firstResult(); + + createPersonalAvailabilityDirect(other, companyB(), LocalDate.now().plusDays(10), LocalDate.now().plusDays(12), + "Other PTO"); + + given().when().get(BASE_PATH + "/user/" + other.id).then().statusCode(403); + } + + @Test + @TestSecurity(user = TAM_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM_EMAIL), @Claim(key = "sub", value = "avail-tam") }) + void tam_canManageOwnAvailability() { + seedUsers(); + companyA(); + + Response create = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), "TAM PTO")).when() + .post(BASE_PATH); + create.then().statusCode(201); + Long id = create.jsonPath().getLong("id"); + + given().when().get(BASE_PATH + "/me").then().statusCode(200).body("id", hasItem(id.intValue())); + + given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(12), LocalDate.now().plusDays(16), "TAM PTO extended")) + .when().put(BASE_PATH + "/" + id).then().statusCode(200); + + given().when().delete(BASE_PATH + "/" + id).then().statusCode(204); + assertEquals(null, UserAvailability.findById(id)); + } + + @Test + @TestSecurity(user = TAM2_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM2_EMAIL), @Claim(key = "sub", value = "avail-tam-2") }) + void tam_updateAnotherUsersAvailability_isForbidden() { + seedUsers(); + Company company = companyA(); + User owner = User.find("email", TAM_EMAIL).firstResult(); + + UserAvailability availability = createPersonalAvailabilityDirect(owner, company, LocalDate.now().plusDays(10), + LocalDate.now().plusDays(15), "Owner PTO"); + + given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(11), LocalDate.now().plusDays(16), "Hijacked")).when() + .put(BASE_PATH + "/" + availability.id).then().statusCode(401); + + UserAvailability reloaded = UserAvailability.findById(availability.id); + assertNotNull(reloaded); + assertEquals(LocalDate.now().plusDays(10), reloaded.startDate); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "avail-user") }) + void user_canViewCompanyAvailability() { + seedUsers(); + companyA(); + + given().when().get(BASE_PATH + "/company").then().statusCode(200); + } + + @Test + @TestSecurity(user = TAM2_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM2_EMAIL), @Claim(key = "sub", value = "avail-tam-2") }) + void tam_deleteAnotherUsersAvailability_isForbidden() { + seedUsers(); + Company company = companyA(); + User owner = User.find("email", TAM_EMAIL).firstResult(); + + UserAvailability availability = createPersonalAvailabilityDirect(owner, company, LocalDate.now().plusDays(10), + LocalDate.now().plusDays(15), "Owner PTO"); + + given().when().delete(BASE_PATH + "/" + availability.id).then().statusCode(401); + + assertNotNull(UserAvailability.findById(availability.id)); + } + + @Test + @TestSecurity(user = TAM_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM_EMAIL), @Claim(key = "sub", value = "avail-tam") }) + void tam_canViewCompanyAvailability() { + seedUsers(); + companyA(); + + given().when().get(BASE_PATH + "/company").then().statusCode(200); + } + + @Test + @TestSecurity(user = TAM_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM_EMAIL), @Claim(key = "sub", value = "avail-tam") }) + void tam_companyWidePost_isForbidden() { + seedUsers(); + Company company = companyA(); + + Response response = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(1), LocalDate.now().plusDays(2), "Company retreat")).when() + .post(BASE_PATH + "/company"); + + response.then().statusCode(401); + assertTrue(service.getForCompany(company).isEmpty()); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), + @Claim(key = "sub", value = "avail-support") }) + void support_canManageOwnAvailability() { + seedUsers(); + companyA(); + + Response create = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), "Support PTO")).when() + .post(BASE_PATH); + create.then().statusCode(201); + Long id = create.jsonPath().getLong("id"); + + given().when().get(BASE_PATH + "/me").then().statusCode(200).body("id", hasItem(id.intValue())); + + given().when().delete(BASE_PATH + "/" + id).then().statusCode(204); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), + @Claim(key = "sub", value = "avail-support") }) + void support_cannotUpdateAnotherUsersPersonalAvailability() { + seedUsers(); + Company company = companyA(); + User owner = User.find("email", USER_EMAIL).firstResult(); + + UserAvailability availability = createPersonalAvailabilityDirect(owner, company, LocalDate.now().plusDays(10), + LocalDate.now().plusDays(15), "Owner PTO"); + + given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(11), LocalDate.now().plusDays(16), "Hijacked")).when() + .put(BASE_PATH + "/" + availability.id).then().statusCode(401); + + UserAvailability reloaded = UserAvailability.findById(availability.id); + assertEquals(LocalDate.now().plusDays(10), reloaded.startDate); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), + @Claim(key = "sub", value = "avail-support") }) + void support_companyWideGet_returnsOnlyOwnCompany() { + seedUsers(); + Company companyA = companyA(); + Company companyB = companyB(); + User supportA = User.find("email", SUPPORT_EMAIL).firstResult(); + User supportB = User.find("email", SUPPORT2_EMAIL).firstResult(); + + UserAvailability ownCompany = createCompanyAvailabilityDirect(supportA, companyA, LocalDate.now().plusDays(10), + LocalDate.now().plusDays(15), "Company A retreat"); + createCompanyAvailabilityDirect(supportB, companyB, LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + "Company B retreat"); + + Response response = given().when().get(BASE_PATH + "/company"); + + response.then().statusCode(200); + assertEquals(List.of(ownCompany.id), response.jsonPath().getList("id", Long.class)); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), + @Claim(key = "sub", value = "avail-support") }) + void support_cannotManageAnotherCompanysCompanyAvailability() { + seedUsers(); + companyA(); + Company companyB = companyB(); + User supportB = User.find("email", SUPPORT2_EMAIL).firstResult(); + + UserAvailability companyAvailabilityB = createCompanyAvailabilityDirect(supportB, companyB, + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), "Company B retreat"); + + given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(11), LocalDate.now().plusDays(16), "Hijacked")).when() + .put(BASE_PATH + "/" + companyAvailabilityB.id).then().statusCode(401); + + given().when().delete(BASE_PATH + "/" + companyAvailabilityB.id).then().statusCode(401); + + assertNotNull(UserAvailability.findById(companyAvailabilityB.id)); + } + + @Test + @TestSecurity(user = SUPERUSER_EMAIL, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER_EMAIL), + @Claim(key = "sub", value = "avail-superuser") }) + void superuser_canManageOwnAvailability() { + seedUsers(); + companyA(); + + Response create = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), "Superuser PTO")).when() + .post(BASE_PATH); + create.then().statusCode(201); + Long id = create.jsonPath().getLong("id"); + + given().when().get(BASE_PATH + "/me").then().statusCode(200).body("id", hasItem(id.intValue())); + + given().when().delete(BASE_PATH + "/" + id).then().statusCode(204); + } + + @Test + @TestSecurity(user = SUPERUSER_EMAIL, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER_EMAIL), + @Claim(key = "sub", value = "avail-superuser") }) + void superuser_companyWideCreateAndGetForOwnCompany_isAllowed() { + seedUsers(); + Company companyB = companyB(); + User superuserB = User.find("email", SUPERUSER2_EMAIL).firstResult(); + + Response create = given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), "Company A shutdown")) + .when().post(BASE_PATH + "/company"); + create.then().statusCode(201); + Long ownCompanyId = create.jsonPath().getLong("id"); + + createCompanyAvailabilityDirect(superuserB, companyB, LocalDate.now().plusDays(20), + LocalDate.now().plusDays(25), "Company B shutdown"); + + Response response = given().when().get(BASE_PATH + "/company"); + + response.then().statusCode(200); + assertEquals(List.of(ownCompanyId), response.jsonPath().getList("id", Long.class)); + } + + @Test + @TestSecurity(user = SUPERUSER_EMAIL, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER_EMAIL), + @Claim(key = "sub", value = "avail-superuser") }) + void superuser_cannotManageAnotherCompanysCompanyAvailability() { + seedUsers(); + companyA(); + Company companyB = companyB(); + User superuserB = User.find("email", SUPERUSER2_EMAIL).firstResult(); + + UserAvailability companyAvailabilityB = createCompanyAvailabilityDirect(superuserB, companyB, + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), "Company B shutdown"); + + given().contentType("application/json") + .body(requestBody(LocalDate.now().plusDays(11), LocalDate.now().plusDays(16), "Hijacked")).when() + .put(BASE_PATH + "/" + companyAvailabilityB.id).then().statusCode(401); + + given().when().delete(BASE_PATH + "/" + companyAvailabilityB.id).then().statusCode(401); + + assertNotNull(UserAvailability.findById(companyAvailabilityB.id)); + } + + @Test + void unauthenticated_companyGet_returnsUnauthorized() { + given().when().get(BASE_PATH + "/company").then().statusCode(401); + } + + @Test + void unauthenticated_companyPost_returnsUnauthorized() { + given().contentType("application/json").body(requestBody(LocalDate.now(), LocalDate.now().plusDays(1), "PTO")) + .when().post(BASE_PATH + "/company").then().statusCode(401); + } + + @Test + void unauthenticated_update_returnsUnauthorized() { + given().contentType("application/json").body(requestBody(LocalDate.now(), LocalDate.now().plusDays(1), "PTO")) + .when().put(BASE_PATH + "/1").then().statusCode(401); + } + + @Test + void unauthenticated_delete_returnsUnauthorized() { + given().when().delete(BASE_PATH + "/1").then().statusCode(401); + } + + @Test + void getWarningsForTicket_reportsParticipantsAndCompanyOutOfOffice() { + seedUsers(); + Company company = companyA(); + User support = User.find("email", SUPPORT_EMAIL).firstResult(); + User tam = User.find("email", TAM_EMAIL).firstResult(); + User regularUser = User.find("email", USER_EMAIL).firstResult(); + + Ticket ticket = createTicketWithParticipants(company, support, tam, regularUser); + + LocalDate today = LocalDate.now(); + createPersonalAvailabilityDirect(regularUser, company, today, today.plusDays(2), "Personal PTO"); + createCompanyAvailabilityDirect(support, company, today, today.plusDays(2), "Company retreat"); + + List warnings = new ArrayList<>(); + QuarkusTransaction.requiringNew() + .run(() -> warnings.addAll(service.getWarningsForTicket(Ticket.findById(ticket.id)))); + + assertTrue(warnings.stream().anyMatch(warning -> warning.contains("Personal PTO")), + "Personal availability of a ticket user should produce a warning: " + warnings); + assertTrue(warnings.stream().anyMatch(warning -> warning.contains("Company retreat")), + "Company-wide availability should produce a warning: " + warnings); + } + + @Test + void getWarningsForTicket_withoutActiveAvailability_returnsNoWarnings() { + seedUsers(); + Company company = companyA(); + User support = User.find("email", SUPPORT_EMAIL).firstResult(); + User tam = User.find("email", TAM_EMAIL).firstResult(); + User regularUser = User.find("email", USER_EMAIL).firstResult(); + + Ticket ticket = createTicketWithParticipants(company, support, tam, regularUser); + + LocalDate today = LocalDate.now(); + createPersonalAvailabilityDirect(regularUser, company, today.plusDays(10), today.plusDays(12), "Future PTO"); + + List warnings = new ArrayList<>(); + QuarkusTransaction.requiringNew() + .run(() -> warnings.addAll(service.getWarningsForTicket(Ticket.findById(ticket.id)))); + + assertTrue(warnings.isEmpty(), "Future availability should not produce warnings: " + warnings); + } +} diff --git a/src/frontend/src/availability/AvailabilityBadge.tsx b/src/frontend/src/availability/AvailabilityBadge.tsx new file mode 100644 index 00000000..37b215d2 --- /dev/null +++ b/src/frontend/src/availability/AvailabilityBadge.tsx @@ -0,0 +1,38 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import { Building2Icon, UserRoundIcon } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import type { AvailabilityScope } from "@/types/domain"; + +const SCOPE_LABELS: Record = { + PERSONAL: "Personal", + COMPANY: "Company", +}; + +function AvailabilityBadge({ scope }: { scope: AvailabilityScope }) { + const isCompany = scope === "COMPANY"; + + return ( + + {isCompany ? ( + + ) : ( + + )} + {SCOPE_LABELS[scope]} + + ); +} + +export { AvailabilityBadge, SCOPE_LABELS }; diff --git a/src/frontend/src/availability/AvailabilityCalendar.tsx b/src/frontend/src/availability/AvailabilityCalendar.tsx new file mode 100644 index 00000000..da329ccf --- /dev/null +++ b/src/frontend/src/availability/AvailabilityCalendar.tsx @@ -0,0 +1,531 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THE ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import { useMemo, useState } from "react"; +import { eachDayOfInterval, format, formatISO } from "date-fns"; +import { + CalendarDaysIcon, + PencilIcon, + PlusIcon, + SearchIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import { toast } from "sonner"; + +import { AvailabilityBadge } from "@/availability/AvailabilityBadge"; +import { AvailabilityDialog } from "@/availability/AvailabilityDialog"; +import DataState from "@/components/common/DataState"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import useJson from "@/hooks/useJson"; +import useSubmissionGuard from "@/hooks/useSubmissionGuard"; +import { cn } from "@/lib/utils"; +import { toQueryString } from "@/utils/formatting"; +import type { Role } from "@/types/app"; +import type { Availability, AvailabilityScope } from "@/types/domain"; + +interface AvailabilityCalendarProps { + className?: string; + role?: Role; +} + +interface DialogState { + open: boolean; + entry: Availability | null; + initialDate?: string; +} + +interface UserSuggestion { + id: number; + name?: string; + title?: string; +} + +interface ViewedUser { + id: number; + name: string; +} + +function toLocalMidnightDate(isoDate: string): Date { + const [year, month, day] = isoDate.slice(0, 10).split("-").map(Number); + return new Date(year, month - 1, day); +} + +function availabilityDaysOf(entries: readonly Availability[]): Date[] { + return entries.flatMap((entry) => + eachDayOfInterval({ + start: toLocalMidnightDate(entry.startDate), + end: toLocalMidnightDate(entry.endDate), + }), + ); +} + +function AvailabilityCalendar({ className, role }: AvailabilityCalendarProps) { + const guard = useSubmissionGuard(); + + const canManageCompany = role === "support" || role === "superuser"; + const canViewOthers = canManageCompany; + const [scope, setScope] = useState("PERSONAL"); + const activeScope = canManageCompany ? scope : "PERSONAL"; + const [refreshKey, setRefreshKey] = useState(0); + const [dialogState, setDialogState] = useState({ + open: false, + entry: null, + initialDate: undefined, + }); + const [viewedUser, setViewedUser] = useState(null); + const [userSearch, setUserSearch] = useState(""); + const [userSearchFocused, setUserSearchFocused] = useState(false); + const [activeUserIndex, setActiveUserIndex] = useState(-1); + + const normalizedUserSearch = userSearch.trim(); + const shouldFetchUserSuggestions = + canViewOthers && normalizedUserSearch.length >= 2 && viewedUser === null; + + const userSuggestionState = useJson<{ items?: UserSuggestion[] }>( + shouldFetchUserSuggestions + ? `/api/users/suggest${toQueryString({ q: normalizedUserSearch })}` + : null, + ); + const userSuggestions = userSuggestionState.data?.items ?? []; + + const viewedUserState = useJson( + viewedUser + ? `/api/availability/user/${viewedUser.id}?refresh=${refreshKey}` + : null, + ); + + const personalState = useJson( + `/api/availability/me?refresh=${refreshKey}`, + ); + const companyState = useJson( + `/api/availability/company?refresh=${refreshKey}`, + ); + + const entries = useMemo(() => { + const data = viewedUser + ? (viewedUserState.data ?? []) + : activeScope === "COMPANY" + ? (companyState.data ?? []) + : (personalState.data ?? []); + + return [...data].sort((a, b) => a.startDate.localeCompare(b.startDate)); + }, [ + viewedUser, + viewedUserState.data, + activeScope, + personalState.data, + companyState.data, + ]); + + const listState = useMemo( + () => ({ + loading: viewedUser + ? viewedUserState.loading + : personalState.loading || companyState.loading, + error: viewedUser + ? viewedUserState.error + : (personalState.error ?? companyState.error), + unauthorized: viewedUser + ? viewedUserState.unauthorized + : personalState.unauthorized || companyState.unauthorized, + forbidden: viewedUser + ? viewedUserState.forbidden + : personalState.forbidden || companyState.forbidden, + empty: entries.length === 0, + data: entries, + }), + [ + viewedUser, + viewedUserState.loading, + viewedUserState.error, + viewedUserState.unauthorized, + viewedUserState.forbidden, + personalState.loading, + personalState.error, + personalState.unauthorized, + personalState.forbidden, + companyState.loading, + companyState.error, + companyState.unauthorized, + companyState.forbidden, + entries, + ], + ); + + const companyBlackoutDays = useMemo( + () => (viewedUser ? [] : availabilityDaysOf(companyState.data ?? [])), + [viewedUser, companyState.data], + ); + + const blackoutTimestamps = useMemo( + () => new Set(companyBlackoutDays.map((d) => d.getTime())), + [companyBlackoutDays], + ); + + const availabilityDays = useMemo( + () => + availabilityDaysOf( + viewedUser ? (viewedUserState.data ?? []) : (personalState.data ?? []), + ).filter((d) => !blackoutTimestamps.has(d.getTime())), + [viewedUser, viewedUserState.data, personalState.data, blackoutTimestamps], + ); + + const refresh = () => { + setRefreshKey((current) => current + 1); + }; + + const clearViewedUser = () => { + setViewedUser(null); + setUserSearch(""); + setActiveUserIndex(-1); + }; + + const selectViewedUser = (suggestion: UserSuggestion) => { + setViewedUser({ + id: suggestion.id, + name: suggestion.name || suggestion.title || `User ${suggestion.id}`, + }); + setUserSearch(""); + setActiveUserIndex(-1); + }; + + function handleUserSearchKeyDown( + event: React.KeyboardEvent, + ) { + if (event.key === "Escape") { + event.preventDefault(); + setUserSearch(""); + setActiveUserIndex(-1); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveUserIndex((prev) => + prev < userSuggestions.length - 1 ? prev + 1 : 0, + ); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveUserIndex((prev) => + prev > 0 ? prev - 1 : userSuggestions.length - 1, + ); + } else if (event.key === "Enter") { + event.preventDefault(); + if (activeUserIndex >= 0 && activeUserIndex < userSuggestions.length) { + selectViewedUser(userSuggestions[activeUserIndex]); + } + } + } + + const closeDialog = () => { + setDialogState({ open: false, entry: null, initialDate: undefined }); + }; + + const openCreate = (date?: Date) => { + setDialogState({ + open: true, + entry: null, + initialDate: date + ? formatISO(date, { representation: "date" }) + : undefined, + }); + }; + + const openEdit = (entry: Availability) => { + setDialogState({ open: true, entry, initialDate: undefined }); + }; + + const confirmDelete = async (entry: Availability) => { + if (!guard.tryEnter()) { + return; + } + + try { + const response = await fetch(`/api/availability/${entry.id}`, { + method: "DELETE", + credentials: "same-origin", + cache: "no-store", + headers: { + "X-Billetsys-Client": "react", + }, + }); + + if (response.status === 401) { + throw new Error("You need to sign in again."); + } + if (response.status === 403) { + throw new Error("You do not have access to this action."); + } + if (!response.ok) { + throw new Error( + toErrorMessage(response.statusText, "Unable to delete availability."), + ); + } + + toast.success("Availability deleted."); + refresh(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Unable to delete availability.", + ); + } finally { + guard.exit(); + } + }; + + return ( +
+ {canViewOthers ? ( +
+
+ + { + setUserSearch(event.target.value); + setActiveUserIndex(-1); + }} + onFocus={() => setUserSearchFocused(true)} + onBlur={() => setUserSearchFocused(false)} + onKeyDown={handleUserSearchKeyDown} + placeholder="View another user's availability" + aria-label="Search users to view their availability" + className="pl-8" + /> +
+ {userSearchFocused && shouldFetchUserSuggestions && ( +
+ {userSuggestions.length === 0 ? ( +

+ No matching users. +

+ ) : ( + userSuggestions.map((suggestion, index) => ( + + )) + )} +
+ )} +
+ ) : null} + + {viewedUser ? ( +
+ + Viewing availability of {viewedUser.name} + + +
+ ) : null} + +
+ {canManageCompany && !viewedUser ? ( + setScope(value as AvailabilityScope)} + > + + Personal + Company + + + ) : null} + + {!viewedUser && ( + + )} +
+ + + {entries.length === 0 ? ( +

+ No availability periods found. +

+ ) : ( +
+ + + openCreate(day)} + /> + + + +
+ {entries.map((entry) => ( + + +
+
+ + + {format(toLocalMidnightDate(entry.startDate), "PPP")} + {" \u2013 "} + {format(toLocalMidnightDate(entry.endDate), "PPP")} + +
+ + {entry.reason ? ( +

+ {entry.reason} +

+ ) : null} +
+ +
+ + + {!viewedUser && ( + <> + + + + + + + + + + + Delete availability? + + + + This action cannot be undone. This will + permanently delete this availability period. + + + + + Cancel + + void confirmDelete(entry)} + > + Delete + + + + + + )} +
+
+
+ ))} +
+
+ )} +
+ + +
+ ); +} + +function toErrorMessage(text: string, fallback: string): string { + const trimmed = text.trim(); + + if (!trimmed) { + return fallback; + } + + if (trimmed.startsWith(" void; + onSaved: () => void; +} + +interface AvailabilityFormState { + startDate: string; + endDate: string; + reason: string; +} + +function AvailabilityDialog({ + open, + scope, + entry, + initialDate, + canManageCompany = false, + onClose, + onSaved, +}: AvailabilityDialogProps) { + const guard = useSubmissionGuard(); + const isEdit = Boolean(entry); + + const [formState, setFormState] = useState({ + startDate: "", + endDate: "", + reason: "", + }); + const [createScope, setCreateScope] = useState( + canManageCompany ? scope : "PERSONAL", + ); + const [saveState, setSaveState] = useState({ saving: false, error: "" }); + + useEffect(() => { + if (!open) { + return; + } + setFormState({ + startDate: entry?.startDate ?? initialDate ?? "", + endDate: entry?.endDate ?? initialDate ?? "", + reason: entry?.reason ?? "", + }); + setCreateScope(canManageCompany ? scope : "PERSONAL"); + setSaveState({ saving: false, error: "" }); + }, [open, entry, initialDate, scope, canManageCompany]); + + const updateFormState = ( + field: keyof AvailabilityFormState, + value: string, + ) => { + setFormState((current) => ({ ...current, [field]: value })); + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + + if (!formState.startDate || !formState.endDate) { + setSaveState({ + saving: false, + error: "Start date and end date are required.", + }); + return; + } + + if (formState.endDate < formState.startDate) { + setSaveState({ + saving: false, + error: "End date cannot be before start date.", + }); + return; + } + + if (!guard.tryEnter()) { + return; + } + + setSaveState({ saving: true, error: "" }); + + const url = isEdit + ? `/api/availability/${entry?.id}` + : canManageCompany && createScope === "COMPANY" + ? "/api/availability/company" + : "/api/availability"; + + try { + const response = await fetch(url, { + method: isEdit ? "PUT" : "POST", + credentials: "same-origin", + cache: "no-store", + headers: { + "Content-Type": "application/json", + "X-Billetsys-Client": "react", + }, + body: JSON.stringify({ + startDate: formState.startDate, + endDate: formState.endDate, + reason: formState.reason.trim() || null, + }), + }); + + if (response.status === 401) { + throw new Error("You need to sign in again."); + } + if (response.status === 403) { + throw new Error("You do not have access to this action."); + } + if (!response.ok) { + throw new Error( + toErrorMessage(await response.text(), "Unable to save availability."), + ); + } + + toast.success(isEdit ? "Availability updated." : "Availability created."); + onSaved(); + onClose(); + } catch (error) { + setSaveState({ + saving: false, + error: + error instanceof Error + ? error.message + : "Unable to save availability.", + }); + } finally { + guard.exit(); + } + }; + + return ( + { + if (!next && !saveState.saving) { + onClose(); + } + }} + > + + + + {isEdit ? "Edit availability" : "Add availability"} + + + Reserve a period of time when you will be unavailable. + + +
+ {isEdit ? ( + + Scope + {entry ? : null} + + ) : ( + + Scope + + + )} +
+ + + Start date * + + + updateFormState("startDate", event.target.value) + } + required + /> + + + + End date * + + + updateFormState("endDate", event.target.value) + } + required + /> + +
+ + Reason + + updateFormState("reason", event.target.value) + } + placeholder="Optional description" + /> + + {saveState.error ? {saveState.error} : null} + + + + +
+
+
+ ); +} + +function toErrorMessage(text: string, fallback: string): string { + const trimmed = text.trim(); + if (!trimmed) { + return fallback; + } + if (trimmed.startsWith("span]:text-xs [&>span]:opacity-70", + "relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 bg-transparent leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70", defaultClassNames.day, className, )} diff --git a/src/frontend/src/pages/AvailabilityPage.tsx b/src/frontend/src/pages/AvailabilityPage.tsx new file mode 100644 index 00000000..21ae9647 --- /dev/null +++ b/src/frontend/src/pages/AvailabilityPage.tsx @@ -0,0 +1,22 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THE ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THIS SOFTWARE IS GOVERNED BY THE TERMS OF THE AGREEMENT. + */ + +import PageHeader from "../components/layout/PageHeader"; +import { AvailabilityCalendar } from "../availability/AvailabilityCalendar"; +import type { SessionPageProps } from "../types/app"; + +export default function AvailabilityPage({ sessionState }: SessionPageProps) { + const role = sessionState.data?.role; + + return ( +
+ + +
+ ); +} diff --git a/src/frontend/src/pages/SupportTicketDetailPage.tsx b/src/frontend/src/pages/SupportTicketDetailPage.tsx index d082855a..6545abe1 100644 --- a/src/frontend/src/pages/SupportTicketDetailPage.tsx +++ b/src/frontend/src/pages/SupportTicketDetailPage.tsx @@ -67,6 +67,8 @@ import { } from "../components/ui/select"; import { Ratings } from "../components/ui/ratings"; import { Card, CardContent } from "../components/ui/card"; +import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert"; +import { CalendarOffIcon } from "lucide-react"; import AsciinemaPlayer from "../components/common/AsciinemaPlayer"; interface SupportTicketDetailPageProps extends SessionPageProps { @@ -744,6 +746,19 @@ export default function SupportTicketDetailPage({ showSummaryField ? ticket.title || formState.title : undefined } /> + {(ticket.availabilityWarnings ?? []).length > 0 && ( + + + Out of office + +
    + {(ticket.availabilityWarnings ?? []).map((warning) => ( +
  • {warning}
  • + ))} +
+
+
+ )}
diff --git a/src/frontend/src/routes/ContentRoutes.tsx b/src/frontend/src/routes/ContentRoutes.tsx index e76a7567..f310690c 100644 --- a/src/frontend/src/routes/ContentRoutes.tsx +++ b/src/frontend/src/routes/ContentRoutes.tsx @@ -9,6 +9,7 @@ import { lazy } from "react"; import { PATHS } from "./paths"; import type { AppRoute, SessionState } from "../types/app"; +import AvailabilityPage from "../pages/AvailabilityPage"; const ManualPage = lazy(() => import("../pages/ManualPage")); const ArticleDetailPage = lazy(() => import("../pages/ArticleDetailPage")); @@ -33,6 +34,11 @@ const LevelsPage = lazy(() => import("../pages/LevelsPage")); export function getContentRoutes(sessionState: SessionState): AppRoute[] { return [ + { + path: PATHS.availability, + element: , + requiresAuth: true, + }, { path: "/companies", element: , diff --git a/src/frontend/src/routes/paths.ts b/src/frontend/src/routes/paths.ts index 5bfd025c..2ddfb6c2 100644 --- a/src/frontend/src/routes/paths.ts +++ b/src/frontend/src/routes/paths.ts @@ -14,6 +14,7 @@ export const PATHS = { profile: "/profile", profilePassword: "/profile/password", reports: "/reports", + availability: "/availability", owner: "/owner", ownerEdit: "/owner/edit", users: "/users", diff --git a/src/frontend/src/types/domain.ts b/src/frontend/src/types/domain.ts index f09b8890..28d7edf7 100644 --- a/src/frontend/src/types/domain.ts +++ b/src/frontend/src/types/domain.ts @@ -12,3 +12,4 @@ export * from "./domain/attachments"; export * from "./domain/content"; export * from "./domain/companies"; export * from "./domain/tickets"; +export * from "./domain/availability"; diff --git a/src/frontend/src/types/domain/availability.ts b/src/frontend/src/types/domain/availability.ts new file mode 100644 index 00000000..179a01bd --- /dev/null +++ b/src/frontend/src/types/domain/availability.ts @@ -0,0 +1,23 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +export type AvailabilityScope = "PERSONAL" | "COMPANY"; + +export interface Availability { + id: number; + startDate: string; + endDate: string; + scope: AvailabilityScope; + reason: string | null; +} + +export interface AvailabilityRequest { + startDate: string; + endDate: string; + reason: string | null; +} diff --git a/src/frontend/src/types/domain/tickets.ts b/src/frontend/src/types/domain/tickets.ts index 662a714c..7eacb21f 100644 --- a/src/frontend/src/types/domain/tickets.ts +++ b/src/frontend/src/types/domain/tickets.ts @@ -108,6 +108,7 @@ export interface SupportTicketDetailRecord extends TicketReference { versions?: VersionInfo[]; messages?: MessageReference[]; events?: EventEntry[]; + availabilityWarnings?: string[]; } export interface CrossReferenceEntry { diff --git a/src/frontend/src/utils/navigation.ts b/src/frontend/src/utils/navigation.ts index 381490bb..4642f2b4 100644 --- a/src/frontend/src/utils/navigation.ts +++ b/src/frontend/src/utils/navigation.ts @@ -28,7 +28,12 @@ export function headerNavigation( } if (role === "support") { - return filterNavigation(navigation, ["Tickets", "Articles", "Users"]); + return filterNavigation(navigation, [ + "Tickets", + "Articles", + "Users", + "Availability", + ]); } if (role === "superuser" || role === "tam") { @@ -37,11 +42,17 @@ export function headerNavigation( "Articles", "Reports", "Users", + "Availability", ]); } if (role === "user") { - return filterNavigation(navigation, ["Tickets", "Articles", "External"]); + return filterNavigation(navigation, [ + "Tickets", + "Articles", + "External", + "Availability", + ]); } return navigation.filter((link) => link.label !== "Profile");