diff --git a/doc/entity-diagram.md b/doc/entity-diagram.md index 2d5a1595..0d756683 100644 --- a/doc/entity-diagram.md +++ b/doc/entity-diagram.md @@ -45,6 +45,7 @@ erDiagram STRING headers_color STRING buttons_color BOOLEAN use_24_hour_clock + INT ticket_auto_close_days STRING singleton_key BIGINT company_id FK } @@ -70,6 +71,9 @@ erDiagram STRING name STRING title STRING status + INT rating + STRING rating_comment + DATETIME resolved_at BIGINT company_id FK BIGINT requester_id FK BIGINT company_entitlement_id FK diff --git a/doc/manual/en/11-tickets.md b/doc/manual/en/11-tickets.md index 3de39ce6..1075b599 100644 --- a/doc/manual/en/11-tickets.md +++ b/doc/manual/en/11-tickets.md @@ -24,6 +24,7 @@ A ticket can hold the main context for a support issue, including: * Affected and resolved versions * Support and TAM assignments * External issue reference +* Ticket rating and feedback * Message history This allows teams to understand both the current state of a case and how it reached that state. @@ -40,7 +41,9 @@ A typical flow looks like this: * The ticket is reviewed and assigned * Support and customer communication continues in the message thread * Status changes reflect current progress -* A resolved case is eventually closed +* The ticket reaches "Resolved" state +* The user provides an optional 10-star rating and feedback comment +* A resolved case is eventually closed, either manually or automatically after a configured inactivity period (default is 7 days) This lifecycle gives both customer-facing roles and operational roles a shared view of progress. @@ -116,6 +119,33 @@ This page is the main working area for understanding a case in context. Cross-references from other tickets that mention this ticket are shown in a dedicated Related section in the ticket header. +## Ratings and feedback + +Once a ticket has been marked as **Resolved**, a rating can be submitted to capture customer satisfaction. A rating consists of a 1–10 star scale and an optional text comment. A ticket can only be rated once. + +### Who can submit a rating + +* **Users** can rate their own tickets — only tickets where they are the requester. +* **Superusers** can rate tickets belonging to their company. A superuser can only see and interact with tickets scoped to the company they belong to. + +Support, TAM, and Admin roles cannot submit ratings. + +### Who can view a rating + +Once a rating has been submitted, it becomes a read-only display on the ticket detail page. Visibility follows the same company-scoping rules as the ticket itself: + +* **Users** see the rating on their own tickets. +* **Superusers** see ratings on all tickets within their company. +* **TAMs** see ratings on tickets for the companies they are assigned to. +* **Support** agents see ratings on all tickets they have access to. +* **Admins** see ratings through the ticket workbench, which provides a system-wide view across all companies. + +### Auto-close + +If a resolved ticket is not rated within a configurable period (default: 7 days), the system automatically closes it. The interval can be configured by an Admin through the **Owner** settings page under "Ticket auto-close." Setting the value to **0** disables auto-close entirely. + +When a ticket is auto-closed, its rating is set to **-1**. This sentinel value distinguishes auto-closed tickets from those that have not yet been rated (`null`) and those with an actual user-submitted rating (`1`–`10`). + ## External tracking Tickets can also include an external issue reference. This makes it easier to connect billetsys with development or defect tracking workflows outside the ticket system itself. diff --git a/src/backend/main/java/ai/mnemosyne_systems/model/Installation.java b/src/backend/main/java/ai/mnemosyne_systems/model/Installation.java index 9cb29ce3..757d6bab 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/model/Installation.java +++ b/src/backend/main/java/ai/mnemosyne_systems/model/Installation.java @@ -50,6 +50,9 @@ public class Installation extends PanacheEntityBase { @Column(name = "use_24_hour_clock") public Boolean use24HourClock; + @Column(name = "ticket_auto_close_days") + public Integer ticketAutoCloseDays; + @Column(name = "singleton_key", nullable = false, unique = true, updatable = false) public String singletonKey = "installation"; diff --git a/src/backend/main/java/ai/mnemosyne_systems/model/Ticket.java b/src/backend/main/java/ai/mnemosyne_systems/model/Ticket.java index b1d6ebc6..6ee47b1c 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/model/Ticket.java +++ b/src/backend/main/java/ai/mnemosyne_systems/model/Ticket.java @@ -70,6 +70,15 @@ public class Ticket extends PanacheEntityBase { @JoinColumn(name = "resolved_version_id") public Version resolvedVersion; + @Column + public Integer rating; + + @Column(name = "rating_comment", length = 2000) + public String ratingComment; + + @Column(name = "resolved_at") + public java.time.LocalDateTime resolvedAt; + @OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true) public List messages = new ArrayList<>(); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerApiResource.java index 96079372..c109f89f 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerApiResource.java @@ -71,6 +71,7 @@ public OwnerResponse update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, Ow installation.headersColor = normalizedHeadersColor; installation.buttonsColor = normalizedButtonsColor; installation.use24HourClock = Boolean.TRUE.equals(request.use24HourClock()); + installation.ticketAutoCloseDays = OwnerResource.clampAutoCloseDays(request.ticketAutoCloseDays()); installation.logoBase64 = trimToNull(request.logoBase64()); installation.backgroundBase64 = trimToNull(request.backgroundBase64()); return toResponse(company); @@ -110,8 +111,9 @@ private OwnerResponse toResponse(Company company) { BrandingProvider.normalizeInstallationColor(installation.headersColor), BrandingProvider.normalizeInstallationColor(installation.buttonsColor), Boolean.TRUE.equals(installation.use24HourClock), company.timezone == null ? null : company.timezone.id, - company.timezone == null ? null : company.timezone.name, supportUsers, tamUsers, supportOptions, - tamOptions, countries, timezones); + company.timezone == null ? null : company.timezone.name, + installation.ticketAutoCloseDays != null ? installation.ticketAutoCloseDays : 7, supportUsers, tamUsers, + supportOptions, tamOptions, countries, timezones); } private UserSummary toUserSummary(User user) { @@ -128,9 +130,9 @@ private String trimToNull(String value) { public record OwnerResponse(Long id, String name, String address1, String address2, String city, String state, String zip, String phoneNumber, Long countryId, String countryName, String logoBase64, String backgroundBase64, String headerFooterColor, String headersColor, String buttonsColor, - boolean use24HourClock, Long timezoneId, String timezoneName, List supportUsers, - List tamUsers, List supportOptions, List tamOptions, - List countries, List timezones) { + boolean use24HourClock, Long timezoneId, String timezoneName, int ticketAutoCloseDays, + List supportUsers, List tamUsers, List supportOptions, + List tamOptions, List countries, List timezones) { } public record UserSummary(Long id, String username, String displayName, String email, String profilePath) { @@ -145,6 +147,6 @@ public record TimezoneOption(Long id, String name, Long countryId) { public record OwnerUpdateRequest(String name, String address1, String address2, String city, String state, String zip, String phoneNumber, Long countryId, Long timezoneId, List supportIds, List tamIds, String headerFooterColor, String headersColor, String buttonsColor, Boolean use24HourClock, - String logoBase64, String backgroundBase64) { + Integer ticketAutoCloseDays, String logoBase64, String backgroundBase64) { } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerResource.java index 22b9e95b..665c58a8 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerResource.java @@ -61,7 +61,8 @@ public Response updateOwner(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @F @FormParam("phoneNumber") String phoneNumber, @FormParam("supportIds") List supportIds, @FormParam("tamIds") List tamIds, @FormParam("headerFooterColor") String headerFooterColor, @FormParam("headersColor") String headersColor, @FormParam("buttonsColor") String buttonsColor, - @FormParam("use24HourClock") Boolean use24HourClock) { + @FormParam("use24HourClock") Boolean use24HourClock, + @FormParam("ticketAutoCloseDays") Integer ticketAutoCloseDays) { requireAdmin(auth); Company company = findOwnerCompany(); if (name == null || name.isBlank()) { @@ -87,6 +88,7 @@ public Response updateOwner(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @F installation.headersColor = normalizedHeadersColor; installation.buttonsColor = normalizedButtonsColor; installation.use24HourClock = Boolean.TRUE.equals(use24HourClock); + installation.ticketAutoCloseDays = clampAutoCloseDays(ticketAutoCloseDays); return Response.seeOther(URI.create("/owner")).build(); } @@ -145,6 +147,9 @@ static Installation findOrCreateInstallation(Company company) { if (installation.use24HourClock == null) { installation.use24HourClock = false; } + if (installation.ticketAutoCloseDays == null) { + installation.ticketAutoCloseDays = 7; + } installation.persist(); return installation; } @@ -168,6 +173,13 @@ static String firstNonBlank(String... values) { return null; } + static int clampAutoCloseDays(Integer value) { + if (value == null) { + return 7; + } + return Math.max(0, Math.min(365, value)); + } + static User requireAdmin(String auth) { User user = AuthHelper.findUser(auth); if (!AuthHelper.isAdmin(user)) { 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 6e747f6e..032c4192 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java @@ -174,7 +174,7 @@ public UserTicketApiResource.RoleTicketDetailResponse detail(@CookieParam(AuthHe : ticket.companyEntitlement.entitlement.name, superuserResource.resolveLowestEntitlementLevelName(ticket), ticket.externalIssueLink, ticket.affectsVersion == null ? null : ticket.affectsVersion.id, - ticket.resolvedVersion == null ? null : ticket.resolvedVersion.id, + ticket.resolvedVersion == null ? null : ticket.resolvedVersion.id, ticket.rating, ticket.ratingComment, superuserResource.availableVersions(ticket).stream() .map(version -> new SupportTicketApiResource.VersionOption(version.id, version.name, version.date == null ? null : version.date.toString())) diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportResource.java index 01757369..a01bdd38 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportResource.java @@ -405,6 +405,13 @@ public Response updateTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, throw new BadRequestException("Entitlement is required"); } String previousStatus = ticketEmailService.computeEffectiveStatus(ticket, ticket.status); + if (sameStatus(status, "Resolved") || sameStatus(status, "Closed")) { + if (ticket.resolvedAt == null) { + ticket.resolvedAt = java.time.LocalDateTime.now(); + } + } else { + ticket.resolvedAt = null; + } ticket.title = normalizedTitle; ticket.status = status; ticket.company = company; 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 4f486851..950058be 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java @@ -167,11 +167,11 @@ public SupportTicketDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) S : ticket.companyEntitlement.entitlement.name, ticket.companyEntitlement == null || ticket.companyEntitlement.supportLevel == null ? null : ticket.companyEntitlement.supportLevel.name, - ticket.externalIssueLink, ticket.affectsVersion == null ? null : ticket.affectsVersion.id, - ticket.resolvedVersion == null ? null : ticket.resolvedVersion.id, Version - . list("entitlement = ?1 order by date asc, id asc", - ticket.companyEntitlement == null ? null : ticket.companyEntitlement.entitlement) - .stream() + ticket.externalIssueLink, ticket.affectsVersion == null ? null + : ticket.affectsVersion.id, + ticket.resolvedVersion == null ? null : ticket.resolvedVersion.id, ticket.rating, ticket.ratingComment, + Version. list("entitlement = ?1 order by date asc, id asc", + ticket.companyEntitlement == null ? null : ticket.companyEntitlement.entitlement).stream() .map(version -> new VersionOption(version.id, version.name, version.date == null ? null : version.date.toString())) .toList(), @@ -430,9 +430,9 @@ public record AttachmentEntry(Long id, String name, String mimeType, String size public record SupportTicketDetailResponse(Long id, String name, String title, String displayStatus, int assignedCount, int openCount, Long companyId, String companyName, Long categoryId, String categoryName, Long companyEntitlementId, String entitlementName, String levelName, String externalIssueLink, - Long affectsVersionId, Long resolvedVersionId, List versions, - List categories, List supportUsers, List tamUsers, - List messages, boolean ticketEntitlementExpired, String actionPath, String messageActionPath, - String exportPath, List statusOptions) { + Long affectsVersionId, Long resolvedVersionId, Integer rating, String ratingComment, + List versions, List categories, List supportUsers, + List tamUsers, List messages, boolean ticketEntitlementExpired, + String actionPath, String messageActionPath, String exportPath, List statusOptions) { } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketRatingApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketRatingApiResource.java new file mode 100644 index 00000000..73ee9601 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketRatingApiResource.java @@ -0,0 +1,91 @@ +/* + * 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.Ticket; +import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.util.AuthHelper; +import io.smallrye.common.annotation.Blocking; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.CookieParam; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.net.URI; + +@Path("/tickets") +@Consumes(MediaType.APPLICATION_FORM_URLENCODED) +@Produces(MediaType.TEXT_HTML) +@Blocking +public class TicketRatingApiResource { + + private static final int MAX_COMMENT_LENGTH = 2000; + + @POST + @Path("/{id}/rating") + @Transactional + public Response submitRating(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, + @HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id, + @FormParam("rating") Integer rating, @FormParam("ratingComment") String ratingComment) { + + User user = AuthHelper.findUser(auth); + if (user == null || (!User.TYPE_USER.equalsIgnoreCase(user.type) && !AuthHelper.isSuperuser(user))) { + throw new WebApplicationException(Response.seeOther(URI.create("/login")).build()); + } + + Ticket ticket = Ticket.findById(id); + if (ticket == null) { + throw new NotFoundException(); + } + + if (!MessageVisibilitySupport.canAccessTicket(user, ticket)) { + throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); + } + + if (!"Resolved".equalsIgnoreCase(ticket.status)) { + throw new BadRequestException("Ticket must be resolved to be rated."); + } + + if (ticket.rating != null) { + throw new BadRequestException("This ticket has already been rated."); + } + + if (rating == null || rating < 1 || rating > 10) { + throw new BadRequestException("Rating must be between 1 and 10."); + } + + ticket.rating = rating; + ticket.ratingComment = normalizeComment(ratingComment); + + if ("react".equalsIgnoreCase(client)) { + return Response.ok(java.util.Map.of("redirectTo", "")).type(MediaType.APPLICATION_JSON).build(); + } + return ReactRedirectSupport.redirect(client, "/tickets/" + ticket.id); + } + + private String normalizeComment(String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + String trimmed = comment.trim(); + if (trimmed.length() > MAX_COMMENT_LENGTH) { + trimmed = trimmed.substring(0, MAX_COMMENT_LENGTH); + } + return trimmed; + } +} diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketResource.java index 173ec9e5..b6b78e5b 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketResource.java @@ -228,6 +228,14 @@ public Response update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathPa throw new BadRequestException("Entitlement is required"); } String previousStatus = ticketEmailService.computeEffectiveStatus(ticket, ticket.status); + if (sameStatus(status, "Resolved") || sameStatus(status, "Closed")) { + if (ticket.resolvedAt == null) { + ticket.resolvedAt = java.time.LocalDateTime.now(); + } + } else { + ticket.resolvedAt = null; + } + ticket.status = status; ticket.company = company; ticket.requester = user; diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketWorkbenchApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketWorkbenchApiResource.java index ec1fb4c9..9b9be76f 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketWorkbenchApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketWorkbenchApiResource.java @@ -110,7 +110,8 @@ Category. listAll().stream().map(category -> new CategoryOption(catego ticket.companyEntitlement == null ? null : ticket.companyEntitlement.id, ticket.category == null ? null : ticket.category.id, ticket.externalIssueLink, ticket.affectsVersion == null ? null : ticket.affectsVersion.id, - ticket.resolvedVersion == null ? null : ticket.resolvedVersion.id)); + ticket.resolvedVersion == null ? null : ticket.resolvedVersion.id, ticket.rating, + ticket.ratingComment)); } @GET @@ -220,6 +221,7 @@ public record MessageSummary(Long id, String body, String dateLabel, boolean isP } public record TicketFormData(Long id, String title, String status, Long companyId, Long companyEntitlementId, - Long categoryId, String externalIssueLink, Long affectsVersionId, Long resolvedVersionId) { + Long categoryId, String externalIssueLink, Long affectsVersionId, Long resolvedVersionId, Integer rating, + String ratingComment) { } } 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 649799ed..4998f352 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java @@ -178,7 +178,7 @@ public RoleTicketDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) Stri : ticket.companyEntitlement.entitlement.name, tamView ? userResource.resolveLowestEntitlementLevelName(ticket) : null, ticket.externalIssueLink, ticket.affectsVersion == null ? null : ticket.affectsVersion.id, - ticket.resolvedVersion == null ? null : ticket.resolvedVersion.id, + ticket.resolvedVersion == null ? null : ticket.resolvedVersion.id, ticket.rating, ticket.ratingComment, userResource.availableVersions(ticket).stream() .map(version -> new SupportTicketApiResource.VersionOption(version.id, version.name, version.date == null ? null : version.date.toString())) @@ -346,7 +346,8 @@ private User requireUser(String auth) { public record RoleTicketDetailResponse(Long id, String name, String title, String displayStatus, int assignedCount, int openCount, Long companyId, String companyName, Long categoryId, String categoryName, Long companyEntitlementId, String entitlementName, String levelName, String externalIssueLink, - Long affectsVersionId, Long resolvedVersionId, List versions, + Long affectsVersionId, Long resolvedVersionId, Integer rating, String ratingComment, + List versions, List categories, List supportUsers, String secondaryUsersLabel, List secondaryUsers, diff --git a/src/backend/main/java/ai/mnemosyne_systems/service/TicketAutoCloseService.java b/src/backend/main/java/ai/mnemosyne_systems/service/TicketAutoCloseService.java new file mode 100644 index 00000000..609ce34d --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/service/TicketAutoCloseService.java @@ -0,0 +1,65 @@ +/* + * 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.service; + +import ai.mnemosyne_systems.model.Installation; +import ai.mnemosyne_systems.model.Ticket; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.time.LocalDateTime; +import org.jboss.logging.Logger; + +@ApplicationScoped +public class TicketAutoCloseService { + + private static final Logger LOGGER = Logger.getLogger(TicketAutoCloseService.class); + private static final int DEFAULT_AUTO_CLOSE_DAYS = 7; + + @Inject + TicketEmailService ticketEmailService; + + // Run once a day at 2 AM + @Scheduled(cron = "0 0 2 * * ?") + @Transactional + public void autoCloseTickets() { + int autoCloseDays = resolveAutoCloseDays(); + + if (autoCloseDays <= 0) { + LOGGER.info("Ticket auto-close is disabled (ticketAutoCloseDays = 0)."); + return; + } + + LocalDateTime threshold = LocalDateTime.now().minusDays(autoCloseDays); + + // Find resolved tickets with no rating where resolvedAt exceeds threshold or is missing (legacy rows) + java.util.List ticketsToClose = Ticket + .find("lower(status) = 'resolved' and rating is null and " + "(resolvedAt is null or resolvedAt < ?1)", + threshold) + .list(); + + for (Ticket ticket : ticketsToClose) { + LOGGER.infof("Auto-closing ticket %d (%s) as it has been resolved for %d days with no rating.", ticket.id, + ticket.name, autoCloseDays); + String previousStatus = ticketEmailService.computeEffectiveStatus(ticket, ticket.status); + ticket.rating = -1; + ticket.status = "Closed"; + ticketEmailService.notifyStatusChange(ticket, previousStatus, null); + } + } + + private int resolveAutoCloseDays() { + Installation installation = Installation.find("singletonKey", "installation").firstResult(); + if (installation != null && installation.ticketAutoCloseDays != null) { + return installation.ticketAutoCloseDays; + } + return DEFAULT_AUTO_CLOSE_DAYS; + } +} diff --git a/src/backend/main/resources/application.properties b/src/backend/main/resources/application.properties index 47037a95..2abfae4e 100644 --- a/src/backend/main/resources/application.properties +++ b/src/backend/main/resources/application.properties @@ -41,6 +41,8 @@ quarkus.mailer.mock=${MAIL_MOCK:true} %test.quarkus.mailer.mock=true ticket.mail.incoming.enabled=${MAIL_INCOMING_ENABLED:true} + + ticket.mailbox.enabled=${MAILBOX_ENABLED:false} ticket.mailbox.poll-interval=${MAILBOX_POLL_INTERVAL:5m} ticket.mailbox.protocol=${MAILBOX_PROTOCOL:imap} diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/AccessTestSupport.java b/src/backend/test/java/ai/mnemosyne_systems/resource/AccessTestSupport.java index e587d257..39cec76f 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/AccessTestSupport.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/AccessTestSupport.java @@ -551,4 +551,28 @@ void deleteCompany(String cookie, Long companyId) { RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) .post("/companies/" + companyId + "/delete").then().statusCode(303); } + + @Transactional + Ticket ensureResolvedTicket(Long companyId) { + Ticket ticket = ensureTicket(companyId); + ticket.status = "Resolved"; + ticket.resolvedAt = java.time.LocalDateTime.now(); + return ticket; + } + + @Transactional + void setTicketRating(Long ticketId, Integer rating, String comment) { + Ticket ticket = Ticket.findById(ticketId); + ticket.rating = rating; + ticket.ratingComment = comment; + } + + @Transactional + void setTicketStatus(Long ticketId, String status) { + Ticket ticket = Ticket.findById(ticketId); + ticket.status = status; + if ("Resolved".equalsIgnoreCase(status) && ticket.resolvedAt == null) { + ticket.resolvedAt = java.time.LocalDateTime.now(); + } + } } diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/TicketRatingAccessTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/TicketRatingAccessTest.java new file mode 100644 index 00000000..0f56a1ac --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/TicketRatingAccessTest.java @@ -0,0 +1,269 @@ +/* + * 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.Ticket; +import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.util.AuthHelper; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class TicketRatingAccessTest extends AccessTestSupport { + + @Test + void userCanRateOwnResolvedTicket() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating User Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + + String cookie = login("user", "user"); + + RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") + .contentType(ContentType.URLENC).formParam("rating", 8).formParam("ratingComment", "Great support!") + .post("/tickets/" + ticket.id + "/rating").then().statusCode(200) + .body("redirectTo", Matchers.equalTo("")); + + Ticket rated = refreshedTicket(ticket.id); + Assertions.assertEquals(8, rated.rating); + Assertions.assertEquals("Great support!", rated.ratingComment); + } + + @Test + void superuserCanRateCompanyTicket() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser1"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating Superuser Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai", + "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + + String cookie = login("superuser1", "superuser1"); + + RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") + .contentType(ContentType.URLENC).formParam("rating", 10).formParam("ratingComment", "Excellent!") + .post("/tickets/" + ticket.id + "/rating").then().statusCode(200) + .body("redirectTo", Matchers.equalTo("")); + + Ticket rated = refreshedTicket(ticket.id); + Assertions.assertEquals(10, rated.rating); + Assertions.assertEquals("Excellent!", rated.ratingComment); + } + + @Test + void supportCannotSubmitRating() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating Support Deny Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + + String cookie = login("support1", "support1"); + + // Support role should be redirected to login (role not allowed) + RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) + .contentType(ContentType.URLENC).formParam("rating", 7).post("/tickets/" + ticket.id + "/rating").then() + .statusCode(303).header("Location", Matchers.endsWith("/login")); + } + + @Test + void tamCannotSubmitRating() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating TAM Deny Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + + String cookie = login("tam1", "tam1"); + + RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) + .contentType(ContentType.URLENC).formParam("rating", 7).post("/tickets/" + ticket.id + "/rating").then() + .statusCode(303).header("Location", Matchers.endsWith("/login")); + } + + @Test + void superuserCannotRateOtherCompanyTicket() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("other-su", "other-su@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "other-su"); + ensureDefaultCategories(); + Long companyA = ensureCompany("Rating Company A"); + ensureCompanyUsers(companyA, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Long companyB = ensureCompany("Rating Company B"); + ensureCompanyUsers(companyB, "other-su@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyA); + + // Superuser from Company B tries to rate Company A's ticket + String cookie = login("other-su", "other-su"); + + RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) + .contentType(ContentType.URLENC).formParam("rating", 5).post("/tickets/" + ticket.id + "/rating").then() + .statusCode(303).header("Location", Matchers.endsWith("/")); + } + + @Test + void ratingBelowOneIsRejected() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating Range Low Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + + String cookie = login("user", "user"); + + RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType(ContentType.URLENC) + .formParam("rating", 0).post("/tickets/" + ticket.id + "/rating").then().statusCode(400); + } + + @Test + void ratingAboveTenIsRejected() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating Range High Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + + String cookie = login("user", "user"); + + RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType(ContentType.URLENC) + .formParam("rating", 11).post("/tickets/" + ticket.id + "/rating").then().statusCode(400); + } + + @Test + void cannotRateNonResolvedTicket() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating Not Resolved Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureTicket(companyId); // status is "Assigned", not "Resolved" + + String cookie = login("user", "user"); + + RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType(ContentType.URLENC) + .formParam("rating", 5).post("/tickets/" + ticket.id + "/rating").then().statusCode(400); + } + + @Test + void cannotRateAlreadyRatedTicket() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating Already Rated Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + setTicketRating(ticket.id, 7, "Already rated"); + + String cookie = login("user", "user"); + + RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType(ContentType.URLENC) + .formParam("rating", 9).formParam("ratingComment", "Try again") + .post("/tickets/" + ticket.id + "/rating").then().statusCode(400); + + // Verify original rating is unchanged + Ticket unchanged = refreshedTicket(ticket.id); + Assertions.assertEquals(7, unchanged.rating); + Assertions.assertEquals("Already rated", unchanged.ratingComment); + } + + @Test + void ratingWithoutCommentSetsCommentToNull() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating No Comment Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + + String cookie = login("user", "user"); + + RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") + .contentType(ContentType.URLENC).formParam("rating", 5).formParam("ratingComment", "") + .post("/tickets/" + ticket.id + "/rating").then().statusCode(200); + + Ticket rated = refreshedTicket(ticket.id); + Assertions.assertEquals(5, rated.rating); + Assertions.assertNull(rated.ratingComment); + } + + @Test + void ratingIsVisibleInUserTicketDetailResponse() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating Visible User Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + setTicketRating(ticket.id, 9, "Wonderful service"); + + String cookie = login("user", "user"); + + RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/tickets/" + ticket.id).then() + .statusCode(200).body("rating", Matchers.equalTo(9)) + .body("ratingComment", Matchers.equalTo("Wonderful service")); + } + + @Test + void ratingIsVisibleInSupportTicketDetailResponse() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating Visible Support Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + setTicketRating(ticket.id, 4, "Could be better"); + + String cookie = login("support1", "support1"); + + RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/support/tickets/" + ticket.id).then() + .statusCode(200).body("rating", Matchers.equalTo(4)) + .body("ratingComment", Matchers.equalTo("Could be better")); + } + + @Test + void unauthenticatedUserCannotRate() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureDefaultCategories(); + Long companyId = ensureCompany("Rating Unauth Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureResolvedTicket(companyId); + + // No cookie — should redirect to login + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("rating", 5) + .post("/tickets/" + ticket.id + "/rating").then().statusCode(303) + .header("Location", Matchers.endsWith("/login")); + } +} diff --git a/src/frontend/src/components/ui/ratings.tsx b/src/frontend/src/components/ui/ratings.tsx new file mode 100644 index 00000000..8cee6674 --- /dev/null +++ b/src/frontend/src/components/ui/ratings.tsx @@ -0,0 +1,240 @@ +/* + * 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 * as React from "react"; +import { Star } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const ratingVariants = { + default: { + star: "text-foreground", + emptyStar: "text-muted-foreground", + }, + destructive: { + star: "text-destructive", + emptyStar: "text-destructive/70", + }, + yellow: { + star: "text-yellow-500", + emptyStar: "text-muted", + }, +}; + +type StarElementProps = React.SVGProps & { + size?: number; +}; + +interface RatingsProps extends Omit< + React.ComponentProps<"div">, + "onChange" | "defaultValue" +> { + totalStars?: number; + size?: number; + fill?: boolean; + icon?: React.ReactElement; + variant?: keyof typeof ratingVariants; + asInput?: boolean; + value: number; + onValueChange?: (value: number) => void; +} + +function Ratings({ + totalStars = 10, + size = 20, + fill = true, + icon = , + variant = "yellow", + asInput = false, + onValueChange, + value, + className, + ...props +}: RatingsProps) { + const rating = Math.max(0, Math.min(value || 0, totalStars)); + const fullStars = Math.floor(rating); + const hasPartial = rating % 1 > 0; + const emptyCount = Math.max(0, totalStars - fullStars - (hasPartial ? 1 : 0)); + const selectedValue = asInput ? Math.round(rating) : 0; + + function handleSelect(starValue: number) { + if (asInput) { + onValueChange?.(starValue); + } + } + + function handleKeyDown(event: React.KeyboardEvent, starValue: number) { + if (!asInput) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleSelect(starValue); + } else if (event.key === "ArrowRight" || event.key === "ArrowUp") { + event.preventDefault(); + const next = Math.min(starValue + 1, totalStars); + handleSelect(next); + } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") { + event.preventDefault(); + const prev = Math.max(starValue - 1, 1); + handleSelect(prev); + } + } + + function starTabIndex(starValue: number): number | undefined { + if (!asInput) { + return undefined; + } + if (selectedValue === 0) { + return starValue === 1 ? 0 : -1; + } + return starValue === selectedValue ? 0 : -1; + } + + return ( +
+ {Array.from({ length: fullStars }, (_, i) => { + const starValue = i + 1; + return React.cloneElement(icon, { + key: `filled-${i}`, + size, + className: cn( + fill ? "fill-current" : "fill-transparent", + ratingVariants[variant].star, + asInput && + "cursor-pointer outline-none focus-visible:outline-2 focus-visible:outline-ring rounded-sm", + ), + role: asInput ? "radio" : undefined, + "aria-checked": asInput ? starValue === selectedValue : undefined, + "aria-label": asInput + ? `${starValue} star${starValue !== 1 ? "s" : ""}` + : undefined, + tabIndex: starTabIndex(starValue), + onClick: asInput ? () => handleSelect(starValue) : undefined, + onKeyDown: asInput + ? (e: React.KeyboardEvent) => handleKeyDown(e, starValue) + : undefined, + }); + })} + {hasPartial && ( + handleSelect(fullStars + 1) : undefined} + onKeyDown={ + asInput + ? (e: React.KeyboardEvent) => handleKeyDown(e, fullStars + 1) + : undefined + } + /> + )} + {Array.from({ length: emptyCount }, (_, i) => { + const starValue = fullStars + i + 1 + (hasPartial ? 1 : 0); + return React.cloneElement(icon, { + key: `empty-${starValue}`, + size, + className: cn( + ratingVariants[variant].emptyStar, + asInput && + "cursor-pointer outline-none focus-visible:outline-2 focus-visible:outline-ring rounded-sm", + ), + role: asInput ? "radio" : undefined, + "aria-checked": asInput ? false : undefined, + "aria-label": asInput + ? `${starValue} star${starValue !== 1 ? "s" : ""}` + : undefined, + tabIndex: starTabIndex(starValue), + onClick: asInput ? () => handleSelect(starValue) : undefined, + onKeyDown: asInput + ? (e: React.KeyboardEvent) => handleKeyDown(e, starValue) + : undefined, + }); + })} +
+ ); +} + +interface PartialStarProps { + fillPercentage: number; + size: number; + className?: string; + icon: React.ReactElement; + interactive?: boolean; + starValue: number; + selected?: boolean; + tabIndex?: number; + onClick?: () => void; + onKeyDown?: (event: React.KeyboardEvent) => void; +} + +function PartialStar({ + fillPercentage, + size, + className, + icon, + interactive, + starValue, + selected, + tabIndex, + onClick, + onKeyDown, +}: PartialStarProps) { + return ( +
+ {React.cloneElement(icon, { + size, + className: cn("fill-transparent", className), + })} +
+ {React.cloneElement(icon, { + size, + className: cn("fill-current", className), + })} +
+
+ ); +} + +export { Ratings }; diff --git a/src/frontend/src/pages/OwnerPage.tsx b/src/frontend/src/pages/OwnerPage.tsx index 2d89a2ca..9f427f8f 100644 --- a/src/frontend/src/pages/OwnerPage.tsx +++ b/src/frontend/src/pages/OwnerPage.tsx @@ -63,6 +63,7 @@ interface OwnerFormState { headersColor: string; buttonsColor: string; use24HourClock: boolean; + ticketAutoCloseDays: number; supportIds: Array; tamIds: Array; } @@ -97,6 +98,60 @@ function ClockFormatField({ ); } +function AutoCloseDaysField({ + value, + onChange, + disabled = false, +}: { + value: number; + onChange?: (value: number) => void; + disabled?: boolean; +}) { + return ( +
+
+
+ Auto-close unrated tickets +
+

+ {value === 0 ? ( + "Disabled — resolved tickets will not be auto-closed." + ) : ( + <> + Resolved tickets without a rating are closed after{" "} + + {value} {value === 1 ? "day" : "days"} + + . + + )} +

+
+ {!disabled && onChange && ( + { + const parsed = parseInt(e.target.value, 10); + if (!isNaN(parsed)) { + onChange(Math.max(0, Math.min(365, parsed))); + } + }} + className="w-20 text-center" + aria-label="Auto-close days" + /> + )} + {disabled && ( + + {value === 0 ? "Off" : `${value} days`} + + )} +
+ ); +} + function BrandingColorTable({ colors, }: { @@ -307,6 +362,15 @@ export function OwnerPage(props: SessionPageProps) { disabled /> + + + Ticket auto-close + + + Colors user.id), tamIds: owner.tamUsers.map((user) => user.id), }); @@ -384,6 +449,12 @@ export function OwnerEditPage(props: SessionPageProps) { ); }; + const updateNumericField = (field: "ticketAutoCloseDays", value: number) => { + setFormState((current) => + current ? { ...current, [field]: value } : current, + ); + }; + const openLogoPicker = () => { logoInputRef.current?.click(); }; @@ -467,6 +538,7 @@ export function OwnerEditPage(props: SessionPageProps) { body: JSON.stringify({ ...formState, use24HourClock: formState.use24HourClock, + ticketAutoCloseDays: formState.ticketAutoCloseDays, countryId: formState.countryId ? Number(formState.countryId) : null, timezoneId: formState.timezoneId ? Number(formState.timezoneId) @@ -750,6 +822,17 @@ export function OwnerEditPage(props: SessionPageProps) { } /> + + + Ticket auto-close + + + updateNumericField("ticketAutoCloseDays", value) + } + /> + Colors ([]); + const [ratingValue, setRatingValue] = useState(0); + const [ratingComment, setRatingComment] = useState(""); + const [ratingState, setRatingState] = useState({ saving: false, error: "" }); const replyInputRef = useRef(null); const fileInputRef = useRef(null); const messagesHeadingRef = useRef(null); @@ -354,6 +360,8 @@ export default function SupportTicketDetailPage({ ? String(ticket.resolvedVersionId) : "none", }); + setRatingValue(ticket.rating ?? 0); + setRatingComment(ticket.ratingComment ?? ""); }, [ticket]); useEffect(() => { @@ -455,6 +463,51 @@ export default function SupportTicketDetailPage({ setSaveState({ saving: false, error: "" }); }; + const submitRating = async (event: FormEvent) => { + event.preventDefault(); + if (!ticket || !submissionGuard.tryEnter()) { + return; + } + + try { + setRatingState({ saving: true, error: "" }); + const response = await postForm( + `/tickets/${ticket.id}/rating`, + [ + ["rating", String(ratingValue)], + ["ratingComment", ratingComment], + ], + { headers: { "X-Billetsys-Client": "react" } }, + ); + + const redirectPath = await resolvePostRedirectPath( + response, + location.pathname, + ); + + if (redirectPath !== location.pathname) { + toast.success("Rating submitted successfully."); + navigate(redirectPath); + } else { + setRefreshNonce((current) => current + 1); + toast.success("Rating submitted successfully."); + } + } catch (error: unknown) { + setRatingState({ + saving: false, + error: + error instanceof Error ? error.message : "Unable to submit rating.", + }); + toast.error( + error instanceof Error ? error.message : "Unable to submit rating.", + ); + return; + } finally { + submissionGuard.exit(); + } + setRatingState({ saving: false, error: "" }); + }; + const addReplyFiles = (event: ChangeEvent) => { const nextFiles = Array.from(event.target.files || []); if (nextFiles.length === 0) { @@ -870,6 +923,105 @@ export default function SupportTicketDetailPage({ )} + {(ticket.displayStatus === "Resolved" || ticket.rating != null) && ( +
+

+ Rating +

+ {ticket.rating != null && ticket.rating >= 1 ? ( + + + + {ticket.ratingComment && ( +

+ {ticket.ratingComment} +

+ )} +
+
+ ) : ticket.rating === -1 ? ( + + +

+ This ticket was automatically closed without a rating. +

+
+
+ ) : sessionState.data?.role === "user" || + sessionState.data?.role === "superuser" ? ( + + +
+
+
+ + + {ratingState.error && ( +

+ {ratingState.error} +

+ )} +
+
+ +