Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/entity-diagram.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
32 changes: 31 additions & 1 deletion doc/manual/en/11-tickets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
9 changes: 9 additions & 0 deletions src/backend/main/java/ai/mnemosyne_systems/model/Ticket.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message> messages = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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<UserSummary> supportUsers,
List<UserSummary> tamUsers, List<UserSummary> supportOptions, List<UserSummary> tamOptions,
List<CountryOption> countries, List<TimezoneOption> timezones) {
boolean use24HourClock, Long timezoneId, String timezoneName, int ticketAutoCloseDays,
List<UserSummary> supportUsers, List<UserSummary> tamUsers, List<UserSummary> supportOptions,
List<UserSummary> tamOptions, List<CountryOption> countries, List<TimezoneOption> timezones) {
}

public record UserSummary(Long id, String username, String displayName, String email, String profilePath) {
Expand All @@ -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<Long> supportIds, List<Long> tamIds,
String headerFooterColor, String headersColor, String buttonsColor, Boolean use24HourClock,
String logoBase64, String backgroundBase64) {
Integer ticketAutoCloseDays, String logoBase64, String backgroundBase64) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ public Response updateOwner(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @F
@FormParam("phoneNumber") String phoneNumber, @FormParam("supportIds") List<Long> supportIds,
@FormParam("tamIds") List<Long> 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()) {
Expand All @@ -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();
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
.<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.<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(),
Expand Down Expand Up @@ -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<VersionOption> versions,
List<CategoryOption> categories, List<UserReference> supportUsers, List<UserReference> tamUsers,
List<MessageEntry> messages, boolean ticketEntitlementExpired, String actionPath, String messageActionPath,
String exportPath, List<String> statusOptions) {
Long affectsVersionId, Long resolvedVersionId, Integer rating, String ratingComment,
List<VersionOption> versions, List<CategoryOption> categories, List<UserReference> supportUsers,
List<UserReference> tamUsers, List<MessageEntry> messages, boolean ticketEntitlementExpired,
String actionPath, String messageActionPath, String exportPath, List<String> statusOptions) {
}
}
Original file line number Diff line number Diff line change
@@ -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) {

Comment on lines +39 to +45
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());
}
Comment on lines +46 to +58

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);

Comment on lines +72 to +74
if ("react".equalsIgnoreCase(client)) {
return Response.ok(java.util.Map.of("redirectTo", "")).type(MediaType.APPLICATION_JSON).build();
}
return ReactRedirectSupport.redirect(client, "/tickets/" + ticket.id);
}
Comment on lines +39 to +79

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ Category.<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
Expand Down Expand Up @@ -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) {
}
}
Loading
Loading