From fba376497bdcd575a227061aee5d2041d9da5ed6 Mon Sep 17 00:00:00 2001 From: Mohamed Hamed Date: Sat, 31 Oct 2026 19:21:39 +0200 Subject: [PATCH] [#159] feat: add custom fields to tickets --- .../infra/ReactShellFilter.java | 5 +- .../mnemosyne_systems/model/CustomField.java | 64 ++ .../model/CustomFieldOption.java | 42 + .../ai/mnemosyne_systems/model/Ticket.java | 3 + .../model/TicketCustomFieldValue.java | 55 ++ .../model/event/EventConstants.java | 4 + .../resource/AppSessionResource.java | 6 +- .../resource/CustomFieldApiResource.java | 85 ++ .../resource/CustomFieldResource.java | 282 +++++++ .../resource/SuperuserResource.java | 5 + .../resource/SuperuserTicketApiResource.java | 9 +- .../resource/SupportResource.java | 8 +- .../resource/SupportTicketApiResource.java | 15 +- .../resource/TicketResource.java | 11 +- .../resource/TicketWorkbenchApiResource.java | 13 +- .../resource/UserResource.java | 5 + .../resource/UserTicketApiResource.java | 18 +- .../service/TicketCustomFieldService.java | 384 +++++++++ .../main/resources/application.properties | 5 + .../resource/CustomFieldCacheTest.java | 119 +++ .../resource/CustomFieldTest.java | 756 ++++++++++++++++++ .../components/tickets/CustomFieldsFields.tsx | 128 +++ .../src/pages/CustomFieldFormPage.tsx | 433 ++++++++++ src/frontend/src/pages/CustomFieldsPage.tsx | 239 ++++++ src/frontend/src/pages/HomePage.tsx | 12 + .../src/pages/SupportTicketCreatePage.tsx | 64 ++ .../src/pages/SupportTicketDetailPage.tsx | 96 ++- .../src/pages/TicketWorkbenchFormPage.tsx | 54 +- src/frontend/src/routes/ContentRoutes.tsx | 17 + src/frontend/src/types/domain.ts | 1 + src/frontend/src/types/domain/customFields.ts | 49 ++ src/frontend/src/types/domain/tickets.ts | 5 + src/frontend/src/types/forms.ts | 3 + src/frontend/src/utils/customFields.ts | 46 ++ 34 files changed, 2991 insertions(+), 50 deletions(-) create mode 100644 src/backend/main/java/ai/mnemosyne_systems/model/CustomField.java create mode 100644 src/backend/main/java/ai/mnemosyne_systems/model/CustomFieldOption.java create mode 100644 src/backend/main/java/ai/mnemosyne_systems/model/TicketCustomFieldValue.java create mode 100644 src/backend/main/java/ai/mnemosyne_systems/resource/CustomFieldApiResource.java create mode 100644 src/backend/main/java/ai/mnemosyne_systems/resource/CustomFieldResource.java create mode 100644 src/backend/main/java/ai/mnemosyne_systems/service/TicketCustomFieldService.java create mode 100644 src/backend/test/java/ai/mnemosyne_systems/resource/CustomFieldCacheTest.java create mode 100644 src/backend/test/java/ai/mnemosyne_systems/resource/CustomFieldTest.java create mode 100644 src/frontend/src/components/tickets/CustomFieldsFields.tsx create mode 100644 src/frontend/src/pages/CustomFieldFormPage.tsx create mode 100644 src/frontend/src/pages/CustomFieldsPage.tsx create mode 100644 src/frontend/src/types/domain/customFields.ts create mode 100644 src/frontend/src/utils/customFields.ts diff --git a/src/backend/main/java/ai/mnemosyne_systems/infra/ReactShellFilter.java b/src/backend/main/java/ai/mnemosyne_systems/infra/ReactShellFilter.java index 9266fd6c..f3fe6877 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/infra/ReactShellFilter.java +++ b/src/backend/main/java/ai/mnemosyne_systems/infra/ReactShellFilter.java @@ -37,6 +37,7 @@ public class ReactShellFilter implements Filter { Pattern.compile("^/attachments/[^/]+$"), Pattern.compile("^/companies(?:$|/new$|/[^/]+(?:/edit)?$)"), Pattern.compile("^/articles(?:$|/new$|/[^/]+(?:/edit)?$)"), Pattern.compile("^/categories(?:$|/new$|/[^/]+(?:/edit)?$)"), + Pattern.compile("^/custom-fields(?:$|/new$|/[^/]+/edit$)"), Pattern.compile("^/entitlements(?:$|/new$|/[^/]+(?:/edit)?$)"), Pattern.compile("^/levels(?:$|/new$|/[^/]+(?:/edit)?$)"), Pattern.compile("^/support/tickets(?:$|/open$|/closed$|/new$|/[^/]+$)"), @@ -92,8 +93,8 @@ private boolean acceptsReactShell(String accept, String path) { private boolean isLegacyBaseRoute(String path) { return switch (path) { case "/", "/login", "/error", "/not-found", "/profile", "/reports", "/owner", "/users", "/companies", - "/articles", "/categories", "/entitlements", "/levels", "/support/tickets", "/support/users", - "/tam/users", "/user/tickets", "/superuser/tickets", "/superuser/users" -> true; + "/articles", "/categories", "/custom-fields", "/entitlements", "/levels", "/support/tickets", + "/support/users", "/tam/users", "/user/tickets", "/superuser/tickets", "/superuser/users" -> true; default -> false; }; } diff --git a/src/backend/main/java/ai/mnemosyne_systems/model/CustomField.java b/src/backend/main/java/ai/mnemosyne_systems/model/CustomField.java new file mode 100644 index 00000000..0c945903 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/model/CustomField.java @@ -0,0 +1,64 @@ +/* + * 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.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "custom_fields", uniqueConstraints = @UniqueConstraint(name = "uk_custom_fields_name_key", columnNames = "name_key")) +public class CustomField extends PanacheEntityBase { + + public static final String TYPE_TEXT = "TEXT"; + public static final String TYPE_NUMBER = "NUMBER"; + public static final String TYPE_DATE = "DATE"; + public static final String TYPE_SELECT = "SELECT"; + + @Id + @SequenceGenerator(name = "custom_field_seq", sequenceName = "custom_field_seq", allocationSize = 1) + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "custom_field_seq") + public Long id; + + @Column(nullable = false) + public String name; + + @Column(name = "name_key", nullable = false) + public String nameKey; + + @Column(name = "field_type", nullable = false, length = 16) + public String fieldType; + + @Column(name = "is_required", nullable = false, columnDefinition = "boolean default false") + public boolean required; + + @Column(columnDefinition = "text") + public String description; + + @Column(nullable = false) + public boolean active = true; + + @Column(name = "sort_order", nullable = false) + public int sortOrder; + + @OneToMany(mappedBy = "customField", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sortOrder asc, id asc") + public List options = new ArrayList<>(); +} diff --git a/src/backend/main/java/ai/mnemosyne_systems/model/CustomFieldOption.java b/src/backend/main/java/ai/mnemosyne_systems/model/CustomFieldOption.java new file mode 100644 index 00000000..8b557048 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/model/CustomFieldOption.java @@ -0,0 +1,42 @@ +/* + * 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 jakarta.persistence.UniqueConstraint; + +@Entity +@Table(name = "custom_field_options", uniqueConstraints = @UniqueConstraint(name = "uk_custom_field_option_label", columnNames = { + "custom_field_id", "label" })) +public class CustomFieldOption extends PanacheEntityBase { + + @Id + @SequenceGenerator(name = "custom_field_option_seq", sequenceName = "custom_field_option_seq", allocationSize = 1) + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "custom_field_option_seq") + public Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "custom_field_id", nullable = false) + public CustomField customField; + + @Column(nullable = false, columnDefinition = "text") + public String label; + + @Column(name = "sort_order", nullable = false) + public int sortOrder; +} 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 03042b0d..2e3677d8 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/model/Ticket.java +++ b/src/backend/main/java/ai/mnemosyne_systems/model/Ticket.java @@ -84,6 +84,9 @@ public class Ticket extends PanacheEntityBase { @OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true) public List messages = new ArrayList<>(); + @OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true) + public List customFieldValues = new ArrayList<>(); + @ManyToMany @JoinTable(name = "ticket_supports", joinColumns = @JoinColumn(name = "ticket_id"), inverseJoinColumns = @JoinColumn(name = "user_id")) public List supportUsers = new ArrayList<>(); diff --git a/src/backend/main/java/ai/mnemosyne_systems/model/TicketCustomFieldValue.java b/src/backend/main/java/ai/mnemosyne_systems/model/TicketCustomFieldValue.java new file mode 100644 index 00000000..63d56757 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/model/TicketCustomFieldValue.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.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.math.BigDecimal; +import java.time.LocalDate; + +@Entity +@Table(name = "ticket_custom_field_values", uniqueConstraints = @UniqueConstraint(name = "uk_ticket_custom_field", columnNames = { + "ticket_id", "custom_field_id" }), indexes = { + @Index(name = "idx_ticket_custom_field_text", columnList = "custom_field_id, text_value"), + @Index(name = "idx_ticket_custom_field_number", columnList = "custom_field_id, number_value"), + @Index(name = "idx_ticket_custom_field_date", columnList = "custom_field_id, date_value") }) +public class TicketCustomFieldValue extends PanacheEntityBase { + + @Id + @SequenceGenerator(name = "ticket_custom_field_value_seq", sequenceName = "ticket_custom_field_value_seq", allocationSize = 1) + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "ticket_custom_field_value_seq") + public Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "ticket_id", nullable = false) + public Ticket ticket; + + @ManyToOne(optional = false) + @JoinColumn(name = "custom_field_id", nullable = false) + public CustomField customField; + + @Column(name = "text_value", columnDefinition = "text") + public String textValue; + + @Column(name = "number_value", precision = 38, scale = 10) + public BigDecimal numberValue; + + @Column(name = "date_value") + public LocalDate dateValue; +} diff --git a/src/backend/main/java/ai/mnemosyne_systems/model/event/EventConstants.java b/src/backend/main/java/ai/mnemosyne_systems/model/event/EventConstants.java index 94e1f30a..f6d648ba 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/model/event/EventConstants.java +++ b/src/backend/main/java/ai/mnemosyne_systems/model/event/EventConstants.java @@ -67,4 +67,8 @@ private EventConstants() { public static final long VERSION_DELETED = 133L; public static final long USER_ACTIVATED = 134L; public static final long USER_DEACTIVATED = 135L; + public static final long CUSTOM_FIELD_CREATED = 136L; + public static final long CUSTOM_FIELD_UPDATED = 137L; + public static final long CUSTOM_FIELD_DEACTIVATED = 138L; + public static final long CUSTOM_FIELD_DELETED = 139L; } 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 1abe815c..9e1d3cb5 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/AppSessionResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/AppSessionResource.java @@ -100,9 +100,9 @@ private List navigation(User user) { return List.of(new NavLink("Dashboard", "/"), new NavLink("Owner", "/owner"), new NavLink("Companies", "/companies"), new NavLink("Tickets", "/tickets"), new NavLink("Articles", "/articles"), new NavLink("Categories", "/categories"), - new NavLink("Entitlements", "/entitlements"), new NavLink("Levels", "/levels"), - new NavLink("Users", "/users"), new NavLink("Profile", "/profile"), - new NavLink("Reports", "/reports")); + new NavLink("Custom fields", "/custom-fields"), new NavLink("Entitlements", "/entitlements"), + new NavLink("Levels", "/levels"), new NavLink("Users", "/users"), + new NavLink("Profile", "/profile"), new NavLink("Reports", "/reports")); } if (AuthHelper.isSupport(user)) { return List.of(new NavLink("Tickets", "/support/tickets"), new NavLink("Articles", "/articles"), diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/CustomFieldApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/CustomFieldApiResource.java new file mode 100644 index 00000000..45503c83 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/CustomFieldApiResource.java @@ -0,0 +1,85 @@ +/* + * 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.CustomField; +import ai.mnemosyne_systems.service.TicketCustomFieldService; +import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.transaction.Transactional; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; + +@Path("/api/custom-fields") +@Produces(MediaType.APPLICATION_JSON) +@Blocking +@RolesAllowed("admin") +public class CustomFieldApiResource { + + @GET + @Transactional + public CustomFieldListResponse list() { + List items = CustomField. find( + "select distinct cf from CustomField cf left join fetch cf.options order by cf.sortOrder asc, cf.id asc") + .list().stream().map(this::toSummary).toList(); + return new CustomFieldListResponse(true, "/custom-fields/new", items); + } + + @GET + @Path("/bootstrap") + public CustomFieldBootstrapResponse bootstrap() { + return new CustomFieldBootstrapResponse(true, TicketCustomFieldService.FIELD_TYPES); + } + + @GET + @Path("/{id}") + @Transactional + public CustomFieldDetailResponse detail(@PathParam("id") Long id) { + CustomField field = CustomField + .find("select distinct cf from CustomField cf left join fetch cf.options where cf.id = ?1", id) + .firstResult(); + if (field == null) { + throw new NotFoundException(); + } + return new CustomFieldDetailResponse(field.id, field.name, field.fieldType, field.required, field.description, + field.active, field.sortOrder, options(field), "/custom-fields/" + field.id + "/edit", + "/custom-fields/" + field.id + "/deactivate"); + } + + private CustomFieldSummary toSummary(CustomField field) { + return new CustomFieldSummary(field.id, field.name, field.fieldType, field.required, field.active, + field.sortOrder, field.description, options(field), "/custom-fields/" + field.id + "/edit", + "/custom-fields/" + field.id + "/deactivate"); + } + + private List options(CustomField field) { + return field.options == null ? List.of() : field.options.stream().map(option -> option.label).toList(); + } + + public record CustomFieldListResponse(boolean canCreate, String createPath, List items) { + } + + public record CustomFieldBootstrapResponse(boolean canEdit, List fieldTypes) { + } + + public record CustomFieldSummary(Long id, String name, String fieldType, boolean required, boolean active, + int sortOrder, String description, List options, String editPath, String deactivatePath) { + } + + public record CustomFieldDetailResponse(Long id, String name, String fieldType, boolean required, + String description, boolean active, int sortOrder, List options, String editPath, + String deactivatePath) { + } +} diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/CustomFieldResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/CustomFieldResource.java new file mode 100644 index 00000000..c2cbe8a3 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/CustomFieldResource.java @@ -0,0 +1,282 @@ +/* + * 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.CustomField; +import ai.mnemosyne_systems.model.CustomFieldOption; +import ai.mnemosyne_systems.model.TicketCustomFieldValue; +import ai.mnemosyne_systems.model.event.EventConstants; +import ai.mnemosyne_systems.service.EventService; +import ai.mnemosyne_systems.service.TicketCustomFieldService; +import ai.mnemosyne_systems.util.AttachmentHelper; +import ai.mnemosyne_systems.util.CurrentUser; +import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; + +import jakarta.ws.rs.GET; +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.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.net.URI; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput; + +@Path("/custom-fields") +@Consumes(MediaType.APPLICATION_FORM_URLENCODED) +@Produces(MediaType.TEXT_HTML) +@Blocking +@RolesAllowed("admin") +public class CustomFieldResource { + + @Inject + CurrentUser currentUser; + + @Inject + EventService eventService; + + @Inject + TicketCustomFieldService ticketCustomFieldService; + + @GET + public Response list() { + return Response.seeOther(URI.create("/custom-fields")).build(); + } + + @GET + @Path("/create") + public Response createForm() { + return Response.seeOther(URI.create("/custom-fields/new")).build(); + } + + @GET + @Path("/{id}/edit") + public Response editForm(@PathParam("id") Long id) { + if (findWithOptions(id) == null) { + throw new NotFoundException(); + } + return Response.seeOther(URI.create("/custom-fields/" + id + "/edit")).build(); + } + + @GET + @Path("/{id}") + public Response view(@PathParam("id") Long id) { + if (findWithOptions(id) == null) { + throw new NotFoundException(); + } + return Response.seeOther(URI.create("/custom-fields/" + id + "/edit")).build(); + } + + @POST + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Transactional + public Response create(@HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { + String name = requireName(input, null); + String fieldType = requireFieldType(AttachmentHelper.readFormValue(input, "fieldType")); + CustomField field = new CustomField(); + field.name = name; + field.nameKey = TicketCustomFieldService.normalizeNameKey(name); + field.fieldType = fieldType; + field.required = AttachmentHelper.readFormBoolean(input, "required", false); + field.description = normalizeDescription(AttachmentHelper.readFormValue(input, "description")); + field.active = AttachmentHelper.readFormBoolean(input, "active", true); + field.sortOrder = parseSortOrder(AttachmentHelper.readFormValue(input, "sortOrder")); + if (CustomField.TYPE_SELECT.equals(fieldType)) { + List optionLabels = parseOptionLines(AttachmentHelper.readFormValue(input, "options")); + requireSelectOptions(optionLabels); + applyOptions(field, optionLabels); + } + field.persist(); + eventService.record(field.id, EventConstants.CUSTOM_FIELD_CREATED, null, currentUser.get().id, + "Custom field created"); + ticketCustomFieldService.invalidateDefinitions(); + return ReactRedirectSupport.redirect(client, "/custom-fields"); + } + + @POST + @Path("/{id}") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Transactional + public Response update(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client, + MultipartFormDataInput input) { + CustomField field = findWithOptions(id); + if (field == null) { + throw new NotFoundException(); + } + String fieldType = requireFieldType(AttachmentHelper.readFormValue(input, "fieldType")); + if (!fieldType.equals(field.fieldType)) { + throw badRequest("Field type cannot be changed"); + } + String name = requireName(input, id); + field.name = name; + field.nameKey = TicketCustomFieldService.normalizeNameKey(name); + field.required = AttachmentHelper.readFormBoolean(input, "required", field.required); + field.description = normalizeDescription(AttachmentHelper.readFormValue(input, "description")); + field.active = AttachmentHelper.readFormBoolean(input, "active", field.active); + field.sortOrder = parseSortOrder(AttachmentHelper.readFormValue(input, "sortOrder")); + if (CustomField.TYPE_SELECT.equals(fieldType)) { + List optionLabels = parseOptionLines(AttachmentHelper.readFormValue(input, "options")); + requireSelectOptions(optionLabels); + applyOptions(field, optionLabels); + } else { + applyOptions(field, List.of()); + } + eventService.record(field.id, EventConstants.CUSTOM_FIELD_UPDATED, null, currentUser.get().id, + "Custom field updated"); + ticketCustomFieldService.invalidateDefinitions(); + return ReactRedirectSupport.redirect(client, "/custom-fields"); + } + + @POST + @Path("/{id}/deactivate") + @Transactional + public Response deactivate(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client) { + CustomField field = findWithOptions(id); + if (field == null) { + throw new NotFoundException(); + } + field.active = false; + eventService.record(field.id, EventConstants.CUSTOM_FIELD_DEACTIVATED, null, currentUser.get().id, + "Custom field deactivated"); + ticketCustomFieldService.invalidateDefinitions(); + return ReactRedirectSupport.redirect(client, "/custom-fields"); + } + + @POST + @Path("/{id}/delete") + @Transactional + public Response delete(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client) { + CustomField field = findWithOptions(id); + if (field == null) { + throw new NotFoundException(); + } + TicketCustomFieldValue.delete("customField", field); + eventService.record(field.id, EventConstants.CUSTOM_FIELD_DELETED, null, currentUser.get().id, + "Custom field deleted"); + field.delete(); + ticketCustomFieldService.invalidateDefinitions(); + return ReactRedirectSupport.redirect(client, "/custom-fields"); + } + + private CustomField findWithOptions(Long id) { + return CustomField + .find("select distinct cf from CustomField cf left join fetch cf.options where cf.id = ?1", id) + .firstResult(); + } + + private String requireName(MultipartFormDataInput input, Long excludeId) { + String name = TicketCustomFieldService.normalizeName(AttachmentHelper.readFormValue(input, "name")); + if (name == null || name.isBlank()) { + throw badRequest("Name is required"); + } + String nameKey = TicketCustomFieldService.normalizeNameKey(name); + long duplicates = excludeId == null ? CustomField.count("nameKey", nameKey) + : CustomField.count("nameKey = ?1 and id <> ?2", nameKey, excludeId); + if (duplicates > 0) { + throw badRequest("A custom field with this name already exists"); + } + return name; + } + + private String requireFieldType(String raw) { + String fieldType = raw == null ? "" : raw.trim().toUpperCase(Locale.ROOT); + if (!TicketCustomFieldService.FIELD_TYPES.contains(fieldType)) { + throw new BadRequestException( + "Field type must be one of: " + String.join(", ", TicketCustomFieldService.FIELD_TYPES)); + } + return fieldType; + } + + private String normalizeDescription(String description) { + if (description == null) { + return null; + } + String trimmed = description.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private int parseSortOrder(String raw) { + if (raw == null || raw.isBlank()) { + return 0; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException ex) { + throw badRequest("Sort order must be a number"); + } + } + + private List parseOptionLines(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + Map labels = new LinkedHashMap<>(); + for (String line : raw.split("\\R")) { + String label = line.trim(); + if (label.isEmpty()) { + continue; + } + TicketCustomFieldService.validateTextValue("Option label", label); + if (labels.putIfAbsent(label, label) != null) { + throw badRequest("Duplicate option: " + label); + } + } + return new ArrayList<>(labels.keySet()); + } + + private void requireSelectOptions(List labels) { + if (labels.isEmpty()) { + throw badRequest("Select custom fields require at least one option"); + } + } + + private void applyOptions(CustomField field, List labels) { + Map existing = new LinkedHashMap<>(); + for (CustomFieldOption option : field.options) { + existing.put(option.label, option); + } + for (CustomFieldOption option : new ArrayList<>(field.options)) { + if (!labels.contains(option.label)) { + long inUse = TicketCustomFieldValue.count("customField = ?1 and textValue = ?2", field, option.label); + if (inUse > 0) { + throw badRequest("Option '" + option.label + "' is in use and cannot be removed or renamed"); + } + field.options.remove(option); + } + } + for (int index = 0; index < labels.size(); index++) { + String label = labels.get(index); + CustomFieldOption option = existing.get(label); + if (option == null) { + option = new CustomFieldOption(); + option.customField = field; + option.label = label; + field.options.add(option); + } + option.sortOrder = index; + } + } + + private static BadRequestException badRequest(String message) { + return new BadRequestException( + Response.status(Response.Status.BAD_REQUEST).entity(message).type(MediaType.TEXT_PLAIN_TYPE).build()); + } +} diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserResource.java index 103ba9d5..07030193 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserResource.java @@ -22,6 +22,7 @@ import ai.mnemosyne_systems.service.EventService; import ai.mnemosyne_systems.service.TicketEmailService; import ai.mnemosyne_systems.service.TicketBootstrapService; +import ai.mnemosyne_systems.service.TicketCustomFieldService; import ai.mnemosyne_systems.util.AttachmentHelper; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.TicketTimeSupport; @@ -82,6 +83,9 @@ public class SuperuserResource { @Inject TicketBootstrapService ticketBootstrapService; + @Inject + TicketCustomFieldService ticketCustomFieldService; + @GET @Path("superuser") public Response home() { @@ -226,6 +230,7 @@ public Response createTicket(@HeaderParam("X-Billetsys-Client") String client, M ticket.affectsVersion = resolveKnownVersion(affectsVersionId, "Affects"); ticket.category = categoryId != null ? Category.findById(categoryId) : Category.findDefault(); ticket.persist(); + ticketCustomFieldService.saveValues(ticket, AttachmentHelper.readFormValue(input, "customFields"), true); boolean isPublic = AttachmentHelper.readFormBoolean(input, "isPublic", true); eventService.saveTicketEvent(ticket, ticket.requester); Message message = new Message(); 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 1af4a6ea..301a166d 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,7 @@ import ai.mnemosyne_systems.service.DirectoryService; import ai.mnemosyne_systems.service.EventService; import ai.mnemosyne_systems.service.TicketBootstrapService; +import ai.mnemosyne_systems.service.TicketCustomFieldService; import ai.mnemosyne_systems.service.UserAvailabilityService; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.AuthHelper; @@ -63,6 +64,9 @@ public class SuperuserTicketApiResource { @Inject TicketBootstrapService ticketBootstrapService; + @Inject + TicketCustomFieldService ticketCustomFieldService; + @Inject DirectoryService directoryService; @@ -160,7 +164,7 @@ public SupportTicketApiResource.SupportTicketBootstrapResponse bootstrap(@QueryP .map(version -> new SupportTicketApiResource.VersionOption(version.id(), version.name(), version.date() == null ? null : version.date().toString())) .toList(), - "/superuser/tickets"); + "/superuser/tickets", TicketCustomFieldService.toViews(ticketCustomFieldService.activeDefinitions())); } private Long entitlementIdForVersions(Long selectedEntitlementId, @@ -237,7 +241,8 @@ 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, availabilityWarnings); + userUsers.stream().map(this::toUserReference).toList(), eventEntries, availabilityWarnings, + ticketCustomFieldService.valuesForTicket(ticket), false); } @GET 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 bf6c5784..710ecff3 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportResource.java @@ -23,6 +23,7 @@ import ai.mnemosyne_systems.service.EventService; import ai.mnemosyne_systems.service.TicketEmailService; import ai.mnemosyne_systems.service.TicketBootstrapService; +import ai.mnemosyne_systems.service.TicketCustomFieldService; import ai.mnemosyne_systems.util.AttachmentHelper; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.CurrentUser; @@ -79,6 +80,9 @@ public class SupportResource { @Inject TicketBootstrapService ticketBootstrapService; + @Inject + TicketCustomFieldService ticketCustomFieldService; + @GET public Response listTickets() { return Response.seeOther(URI.create("/support/tickets")).build(); @@ -391,6 +395,7 @@ public Response createTicket(@HeaderParam("X-Billetsys-Client") String client, M ticket.affectsVersion = resolveVersion(entitlement, affectsVersionId, "Affects", true); ticket.category = categoryId != null ? Category.findById(categoryId) : Category.findDefault(); ticket.persist(); + ticketCustomFieldService.saveValues(ticket, AttachmentHelper.readFormValue(input, "customFields"), true); eventService.saveTicketEvent(ticket, user); assignCompanyTams(ticket); boolean isPublic = AttachmentHelper.readFormBoolean(input, "isPublic", true); @@ -418,7 +423,7 @@ public Response updateTicket(@HeaderParam("X-Billetsys-Client") String client, @FormParam("companyEntitlementId") Long companyEntitlementId, @FormParam("categoryId") Long categoryId, @FormParam("externalIssueLink") String externalIssueLink, @FormParam("affectsVersionId") Long affectsVersionId, - @FormParam("resolvedVersionId") Long resolvedVersionId) { + @FormParam("resolvedVersionId") Long resolvedVersionId, @FormParam("customFields") String customFields) { User user = currentUser.get(); String normalizedTitle = Ticket.normalizeTitle(title); Ticket ticket = Ticket.findById(id); @@ -446,6 +451,7 @@ public Response updateTicket(@HeaderParam("X-Billetsys-Client") String client, if (entitlement == null) { throw new BadRequestException("Entitlement is required"); } + ticketCustomFieldService.saveValues(ticket, customFields, false, status); String previousStatus = ticketEmailService.computeEffectiveStatus(ticket, ticket.status); if (sameStatus(status, "Resolved") || sameStatus(status, "Closed")) { if (ticket.resolvedAt == null) { 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 6cd10fe4..077d0eed 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.TicketCustomFieldService; import ai.mnemosyne_systems.service.UserAvailabilityService; import ai.mnemosyne_systems.util.CurrentUser; import jakarta.annotation.security.RolesAllowed; @@ -64,6 +65,9 @@ public class SupportTicketApiResource { @Inject UserAvailabilityService userAvailabilityService; + @Inject + TicketCustomFieldService ticketCustomFieldService; + @GET @Transactional public SupportTicketListResponse list(@QueryParam("view") @DefaultValue("assigned") String view, @@ -150,7 +154,7 @@ public SupportTicketBootstrapResponse bootstrap(@QueryParam("companyId") Long co .map(version -> new VersionOption(version.id(), version.name(), version.date() == null ? null : version.date().toString())) .toList(), - "/support/tickets"); + "/support/tickets", TicketCustomFieldService.toViews(ticketCustomFieldService.activeDefinitions())); } private Long entitlementIdForVersions(Long selectedEntitlementId, @@ -215,7 +219,8 @@ 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, availabilityWarnings); + externalUsers.stream().map(this::toUserReference).toList(), eventEntries, availabilityWarnings, + ticketCustomFieldService.valuesForTicket(ticket), !"Closed".equalsIgnoreCase(ticket.status)); } @GET @@ -412,7 +417,8 @@ public record SupportTicketSummary(Long id, String name, String title, String st public record SupportTicketBootstrapResponse(int assignedCount, int openCount, Long selectedCompanyId, String ticketName, String title, List companies, List companyEntitlements, Long selectedCompanyEntitlementId, List categories, Long defaultCategoryId, - VersionOption defaultAffectsVersion, List versions, String submitPath) { + VersionOption defaultAffectsVersion, List versions, String submitPath, + List customFields) { } public record RedirectResponse(String redirectTo) { @@ -452,6 +458,7 @@ 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 availabilityWarnings) { + List externalUsers, List events, List availabilityWarnings, + List customFields, boolean editableCustomFields) { } } 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 e1ff4c0d..63168b8a 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketResource.java @@ -19,6 +19,7 @@ import ai.mnemosyne_systems.service.PdfService; import ai.mnemosyne_systems.service.TicketEmailService; import ai.mnemosyne_systems.service.TicketBootstrapService; +import ai.mnemosyne_systems.service.TicketCustomFieldService; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; @@ -90,6 +91,9 @@ public class TicketResource { @Inject TicketBootstrapService ticketBootstrapService; + @Inject + TicketCustomFieldService ticketCustomFieldService; + @Inject PdfService pdfService; @@ -173,7 +177,8 @@ public Response externalPreview(@QueryParam("url") String url) { @RolesAllowed("support") public Response create(@HeaderParam("X-Billetsys-Client") String client, @FormParam("status") String status, @FormParam("title") String title, @FormParam("companyId") Long companyId, - @FormParam("companyEntitlementId") Long companyEntitlementId, @FormParam("categoryId") Long categoryId) { + @FormParam("companyEntitlementId") Long companyEntitlementId, @FormParam("categoryId") Long categoryId, + @FormParam("customFields") String customFields) { User user = currentUser.get(); String normalizedTitle = Ticket.normalizeTitle(title); if (status == null || status.isBlank()) { @@ -208,6 +213,7 @@ public Response create(@HeaderParam("X-Billetsys-Client") String client, @FormPa ticket.affectsVersion = ticketBootstrapService.defaultAffectsVersion(entitlement); ticket.category = category; ticket.persist(); + ticketCustomFieldService.saveValues(ticket, customFields, true); eventService.saveTicketEvent(ticket, user); return ReactRedirectSupport.redirect(client, "/tickets"); } @@ -221,7 +227,7 @@ public Response update(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Clien @FormParam("companyEntitlementId") Long companyEntitlementId, @FormParam("categoryId") Long categoryId, @FormParam("externalIssueLink") String externalIssueLink, @FormParam("affectsVersionId") Long affectsVersionId, - @FormParam("resolvedVersionId") Long resolvedVersionId) { + @FormParam("resolvedVersionId") Long resolvedVersionId, @FormParam("customFields") String customFields) { User user = currentUser.get(); Ticket ticket = Ticket.findById(id); if (ticket == null) { @@ -245,6 +251,7 @@ public Response update(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Clien if (entitlement == null) { throw new BadRequestException("Entitlement is required"); } + ticketCustomFieldService.saveValues(ticket, customFields, false, status); String previousStatus = ticketEmailService.computeEffectiveStatus(ticket, ticket.status); if (sameStatus(status, "Resolved") || sameStatus(status, "Closed")) { if (ticket.resolvedAt == null) { 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 0cab1ab4..48f6ca99 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketWorkbenchApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketWorkbenchApiResource.java @@ -16,6 +16,7 @@ import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.model.Version; import ai.mnemosyne_systems.service.TicketBootstrapService; +import ai.mnemosyne_systems.service.TicketCustomFieldService; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; @@ -55,6 +56,9 @@ public class TicketWorkbenchApiResource { @Inject TicketBootstrapService ticketBootstrapService; + @Inject + TicketCustomFieldService ticketCustomFieldService; + @GET @Transactional public TicketListResponse list() { @@ -106,6 +110,9 @@ public TicketFormResponse bootstrap(@QueryParam("ticketId") Long ticketId, ticket.company = selectedCompany; ticket.category = defaultCategory; } + List customFields = ticketId == null + ? TicketCustomFieldService.toViews(ticketCustomFieldService.activeDefinitions()) + : ticketCustomFieldService.valuesForTicket(ticket); return new TicketFormResponse(ticketId == null ? "New ticket" : "Edit ticket", ticketId == null ? "/tickets" : "/tickets/" + ticketId, "/tickets", ticketId != null, ticketBootstrapService.allCompanyOptions().stream() @@ -125,7 +132,8 @@ public TicketFormResponse bootstrap(@QueryParam("ticketId") Long ticketId, ticket.category == null ? null : ticket.category.id, ticket.externalIssueLink, defaultVersion == null ? null : defaultVersion.id(), ticket.resolvedVersion == null ? null : ticket.resolvedVersion.id, ticket.rating, - ticket.ratingComment)); + ticket.ratingComment), + customFields); } @GET @@ -193,7 +201,8 @@ public record TicketListItem(Long id, String name, String status, String company public record TicketFormResponse(String title, String submitPath, String cancelPath, boolean edit, List companies, List entitlements, List categories, - List versions, List messages, TicketFormData ticket) { + List versions, List messages, TicketFormData ticket, + List customFields) { } public record CompanyOption(Long id, String name) { diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/UserResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/UserResource.java index ece4f3c1..b03b289d 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/UserResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/UserResource.java @@ -23,6 +23,7 @@ import ai.mnemosyne_systems.service.EventService; import ai.mnemosyne_systems.service.TicketEmailService; import ai.mnemosyne_systems.service.TicketBootstrapService; +import ai.mnemosyne_systems.service.TicketCustomFieldService; import ai.mnemosyne_systems.util.AttachmentHelper; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.TicketTimeSupport; @@ -76,6 +77,9 @@ public class UserResource { @Inject TicketBootstrapService ticketBootstrapService; + @Inject + TicketCustomFieldService ticketCustomFieldService; + @GET @Path("user") public Object home() { @@ -263,6 +267,7 @@ public Response createUserTicket(@HeaderParam("X-Billetsys-Client") String clien ticket.affectsVersion = resolveKnownVersion(affectsVersionId, "Affects"); ticket.category = categoryId != null ? Category.findById(categoryId) : Category.findDefault(); ticket.persist(); + ticketCustomFieldService.saveValues(ticket, AttachmentHelper.readFormValue(input, "customFields"), true); boolean isPublic = AttachmentHelper.readFormBoolean(input, "isPublic", true); eventService.saveTicketEvent(ticket, user); Message message = new Message(); 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 d5a3909e..e3796a54 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.TicketCustomFieldService; import ai.mnemosyne_systems.service.UserAvailabilityService; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.CurrentUser; @@ -69,6 +70,9 @@ public class UserTicketApiResource { @Inject UserAvailabilityService userAvailabilityService; + @Inject + TicketCustomFieldService ticketCustomFieldService; + @GET @Transactional public SupportTicketApiResource.SupportTicketListResponse list( @@ -156,9 +160,11 @@ public SupportTicketApiResource.SupportTicketBootstrapResponse bootstrap(@QueryP defaultVersion == null ? null : new SupportTicketApiResource.VersionOption(defaultVersion.id(), defaultVersion.name(), defaultVersion.date() == null ? null : defaultVersion.date().toString()), - versions.stream().map(version -> new SupportTicketApiResource.VersionOption(version.id(), - version.name(), version.date() == null ? null : version.date().toString())).toList(), - "/user/tickets"); + versions.stream() + .map(version -> new SupportTicketApiResource.VersionOption(version.id(), version.name(), + version.date() == null ? null : version.date().toString())) + .toList(), + "/user/tickets", TicketCustomFieldService.toViews(ticketCustomFieldService.activeDefinitions())); } private Long entitlementIdForVersions(Long selectedEntitlementId, @@ -238,7 +244,8 @@ 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, availabilityWarnings); + userUsers.stream().map(this::toUserReference).toList(), eventEntries, availabilityWarnings, + ticketCustomFieldService.valuesForTicket(ticket), false); } @GET @@ -387,6 +394,7 @@ public record RoleTicketDetailResponse(Long id, String name, String title, Strin boolean editableCategory, boolean editableExternalIssue, boolean editableAffectsVersion, boolean editableResolvedVersion, List externalUsers, List userUsers, List events, - List availabilityWarnings) { + List availabilityWarnings, List customFields, + boolean editableCustomFields) { } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/service/TicketCustomFieldService.java b/src/backend/main/java/ai/mnemosyne_systems/service/TicketCustomFieldService.java new file mode 100644 index 00000000..6abeae33 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/service/TicketCustomFieldService.java @@ -0,0 +1,384 @@ +/* + * 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.CustomField; +import ai.mnemosyne_systems.model.Ticket; +import ai.mnemosyne_systems.model.TicketCustomFieldValue; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.cache.CacheInvalidateAll; +import io.quarkus.cache.CacheResult; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Central custom-field definition lookup, validation and value persistence. + *

+ * Definitions are installation-wide (no company scope) and cached as detached DTOs only. Every definition write must + * call {@link #invalidateDefinitions()} so connected ticket forms see the change immediately; the TTL in + * {@code application.properties} is only a backstop. Values are stored in typed columns so advanced search can query + * them without a redesign. + */ +@ApplicationScoped +public class TicketCustomFieldService { + + public static final List FIELD_TYPES = List.of(CustomField.TYPE_TEXT, CustomField.TYPE_NUMBER, + CustomField.TYPE_DATE, CustomField.TYPE_SELECT); + + /** + * Text-backed custom-field values (TEXT and SELECT) are stored in an indexed column. PostgreSQL B-tree entries are + * limited to roughly 2,704 bytes, so both a character and a UTF-8 byte ceiling are enforced. The byte ceiling is + * the one that protects multi-byte scripts (Arabic, CJK, emoji). + */ + public static final int TEXT_VALUE_MAX_CHARS = 1000; + public static final int TEXT_VALUE_MAX_BYTES = 2000; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int NUMBER_SCALE = 10; + private static final int NUMBER_INTEGER_DIGITS = 28; + + public record CustomFieldDefinition(Long id, String name, String fieldType, boolean required, boolean active, + String description, int sortOrder, List options) { + } + + public record CustomFieldView(Long id, String name, String fieldType, boolean required, boolean active, + String description, int sortOrder, List options, String value) { + } + + @CacheResult(cacheName = "custom-field-definitions") + public List activeDefinitions() { + return CustomField. find( + "select distinct cf from CustomField cf left join fetch cf.options where cf.active = true order by cf.sortOrder asc, cf.id asc") + .list().stream().map(this::toDefinition).toList(); + } + + @CacheInvalidateAll(cacheName = "custom-field-definitions") + public void invalidateDefinitions() { + } + + public static String normalizeName(String name) { + return name == null ? null : name.trim(); + } + + public static String normalizeNameKey(String name) { + String normalized = normalizeName(name); + return normalized == null ? null : normalized.toLowerCase(Locale.ROOT); + } + + /** + * Enforces the shared character and UTF-8 byte ceiling for text stored on the indexed {@code text_value} column. + */ + public static void validateTextValue(String subject, String value) { + if (value == null) { + return; + } + int characters = value.codePointCount(0, value.length()); + if (characters > TEXT_VALUE_MAX_CHARS) { + throw badRequest(subject + " must be at most " + TEXT_VALUE_MAX_CHARS + " characters"); + } + if (value.getBytes(StandardCharsets.UTF_8).length > TEXT_VALUE_MAX_BYTES) { + throw badRequest(subject + " must be at most " + TEXT_VALUE_MAX_BYTES + " UTF-8 bytes"); + } + } + + private static BadRequestException badRequest(String message) { + return new BadRequestException( + Response.status(Response.Status.BAD_REQUEST).entity(message).type(MediaType.TEXT_PLAIN_TYPE).build()); + } + + public static List toViews(List definitions) { + if (definitions == null || definitions.isEmpty()) { + return List.of(); + } + return definitions.stream() + .map(definition -> new CustomFieldView(definition.id(), definition.name(), definition.fieldType(), + definition.required(), definition.active(), definition.description(), definition.sortOrder(), + definition.options(), null)) + .toList(); + } + + /** + * Renders the fields a ticket should show: every active definition plus inactive definitions that already have a + * value on this ticket. Inactive-without-value fields disappear from the form and from the detail page. + */ + public List valuesForTicket(Ticket ticket) { + if (ticket == null) { + return List.of(); + } + List values = TicketCustomFieldValue.find( + "select distinct v from TicketCustomFieldValue v join fetch v.customField cf left join fetch cf.options where v.ticket = ?1", + ticket).list(); + Map byField = new LinkedHashMap<>(); + for (TicketCustomFieldValue value : values) { + if (value.customField != null && value.customField.id != null) { + byField.put(value.customField.id, value); + } + } + List definitions = new ArrayList<>(activeDefinitions()); + for (TicketCustomFieldValue value : values) { + if (value.customField != null && !value.customField.active + && definitions.stream().noneMatch(entry -> entry.id().equals(value.customField.id))) { + definitions.add(toDefinition(value.customField)); + } + } + definitions.sort(Comparator.comparingInt(CustomFieldDefinition::sortOrder) + .thenComparing(CustomFieldDefinition::id, Comparator.nullsLast(Comparator.naturalOrder()))); + return definitions.stream() + .map(definition -> new CustomFieldView(definition.id(), definition.name(), definition.fieldType(), + definition.required(), definition.active(), definition.description(), definition.sortOrder(), + definition.options(), displayValue(byField.get(definition.id())))) + .toList(); + } + + public Map parseSubmission(String customFieldsJson) { + if (customFieldsJson == null || customFieldsJson.isBlank()) { + return Map.of(); + } + JsonNode root; + try { + root = MAPPER.readTree(customFieldsJson); + } catch (JsonProcessingException ex) { + throw badRequest("Custom fields must be valid JSON"); + } + if (root == null || !root.isObject()) { + throw badRequest("Custom fields must be a JSON object"); + } + Map values = new LinkedHashMap<>(); + Iterator> fields = root.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + Long fieldId; + try { + fieldId = Long.valueOf(entry.getKey().trim()); + } catch (NumberFormatException ex) { + throw badRequest("Invalid custom field id: " + entry.getKey()); + } + if (fieldId <= 0) { + throw badRequest("Invalid custom field id: " + entry.getKey()); + } + JsonNode node = entry.getValue(); + if (node == null || node.isNull()) { + values.put(fieldId, ""); + continue; + } + if (!node.isTextual()) { + throw badRequest("Custom field " + fieldId + " must be a string"); + } + values.put(fieldId, node.asText()); + } + return values; + } + + /** + * Validates and persists submitted values. A field that is not present in the JSON is left untouched; an empty + * string clears a non-required field. Required fields are enforced on create, and an existing required value can + * never be cleared on update (tickets created before the field existed stay editable). + */ + public void saveValues(Ticket ticket, String customFieldsJson, boolean creating) { + saveValues(ticket, customFieldsJson, creating, null); + } + + public void saveValues(Ticket ticket, String customFieldsJson, boolean creating, String targetStatus) { + if (ticket == null) { + return; + } + Map submitted = parseSubmission(customFieldsJson); + Map activeFields = activeFieldsById(); + for (Long fieldId : submitted.keySet()) { + if (!activeFields.containsKey(fieldId)) { + throw badRequest("Unknown or inactive custom field: " + fieldId); + } + } + ticket.customFieldValues.size(); + Map existing = new LinkedHashMap<>(); + for (TicketCustomFieldValue value : ticket.customFieldValues) { + if (value.customField != null && value.customField.id != null) { + existing.put(value.customField.id, value); + } + } + + if (creating) { + for (CustomField field : activeFields.values()) { + if (field.required && isBlank(submitted.get(field.id))) { + throw badRequest("Custom field '" + field.name + "' is required"); + } + } + } else if (isClosed(ticket) && (targetStatus == null || isClosedStatus(targetStatus))) { + for (Map.Entry entry : submitted.entrySet()) { + CustomField field = activeFields.get(entry.getKey()); + if (!matches(field, existing.get(entry.getKey()), entry.getValue())) { + throw badRequest("Custom fields cannot be changed on a closed ticket"); + } + } + return; + } + + for (Map.Entry entry : submitted.entrySet()) { + CustomField field = activeFields.get(entry.getKey()); + String raw = entry.getValue(); + TicketCustomFieldValue value = existing.get(field.id); + if (isBlank(raw)) { + if (field.required && value != null && hasValue(value)) { + throw badRequest("Custom field '" + field.name + "' is required"); + } + if (value != null) { + ticket.customFieldValues.remove(value); + } + continue; + } + if (value == null) { + value = new TicketCustomFieldValue(); + value.ticket = ticket; + value.customField = field; + ticket.customFieldValues.add(value); + value.persist(); + } + applyValidatedValue(field, value, raw); + } + } + + private Map activeFieldsById() { + List fields = CustomField + . find( + "select distinct cf from CustomField cf left join fetch cf.options where cf.active = true") + .list(); + Map result = new LinkedHashMap<>(); + for (CustomField field : fields) { + result.put(field.id, field); + } + return result; + } + + private void applyValidatedValue(CustomField field, TicketCustomFieldValue value, String raw) { + switch (field.fieldType) { + case CustomField.TYPE_TEXT -> { + validateTextValue("Custom field '" + field.name + "'", raw); + value.textValue = raw; + value.numberValue = null; + value.dateValue = null; + } + case CustomField.TYPE_NUMBER -> { + value.textValue = null; + value.numberValue = parseNumber(field, raw); + value.dateValue = null; + } + case CustomField.TYPE_DATE -> { + value.textValue = null; + value.numberValue = null; + value.dateValue = parseDate(field, raw); + } + case CustomField.TYPE_SELECT -> { + validateTextValue("Custom field '" + field.name + "'", raw); + if (field.options == null || field.options.stream().noneMatch(option -> option.label.equals(raw))) { + throw badRequest("Custom field '" + field.name + "' must be one of: " + + String.join(", ", optionLabels(field))); + } + value.textValue = raw; + value.numberValue = null; + value.dateValue = null; + } + default -> throw badRequest("Invalid field type for custom field '" + field.name + "'"); + } + } + + private boolean matches(CustomField field, TicketCustomFieldValue value, String raw) { + if (value == null) { + return isBlank(raw); + } + if (isBlank(raw)) { + return false; + } + return switch (field.fieldType) { + case CustomField.TYPE_TEXT, CustomField.TYPE_SELECT -> raw.equals(value.textValue); + case CustomField.TYPE_NUMBER -> + value.numberValue != null && parseNumber(field, raw).compareTo(value.numberValue) == 0; + case CustomField.TYPE_DATE -> value.dateValue != null && parseDate(field, raw).equals(value.dateValue); + default -> false; + }; + } + + private BigDecimal parseNumber(CustomField field, String raw) { + BigDecimal number; + try { + number = new BigDecimal(raw.trim()); + } catch (NumberFormatException ex) { + throw badRequest("Custom field '" + field.name + "' must be a number"); + } + if (number.scale() > NUMBER_SCALE) { + throw badRequest("Custom field '" + field.name + "' must have at most " + NUMBER_SCALE + " decimal places"); + } + if (number.precision() - number.scale() > NUMBER_INTEGER_DIGITS) { + throw badRequest("Custom field '" + field.name + "' is too large for a numeric value"); + } + return number; + } + + private LocalDate parseDate(CustomField field, String raw) { + try { + return LocalDate.parse(raw.trim()); + } catch (DateTimeParseException ex) { + throw badRequest("Custom field '" + field.name + "' must be a valid date (YYYY-MM-DD)"); + } + } + + private String displayValue(TicketCustomFieldValue value) { + if (value == null) { + return null; + } + return switch (value.customField.fieldType) { + case CustomField.TYPE_NUMBER -> + value.numberValue == null ? null : value.numberValue.stripTrailingZeros().toPlainString(); + case CustomField.TYPE_DATE -> value.dateValue == null ? null : value.dateValue.toString(); + default -> value.textValue; + }; + } + + private CustomFieldDefinition toDefinition(CustomField field) { + return new CustomFieldDefinition(field.id, field.name, field.fieldType, field.required, field.active, + field.description, field.sortOrder, optionLabels(field)); + } + + private List optionLabels(CustomField field) { + if (field.options == null || field.options.isEmpty()) { + return List.of(); + } + return field.options.stream().map(option -> option.label).toList(); + } + + private boolean hasValue(TicketCustomFieldValue value) { + return value.textValue != null || value.numberValue != null || value.dateValue != null; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private boolean isClosed(Ticket ticket) { + return ticket.status != null && isClosedStatus(ticket.status); + } + + private boolean isClosedStatus(String status) { + return status != null && "Closed".equalsIgnoreCase(status); + } +} diff --git a/src/backend/main/resources/application.properties b/src/backend/main/resources/application.properties index 0cab700d..60701deb 100644 --- a/src/backend/main/resources/application.properties +++ b/src/backend/main/resources/application.properties @@ -134,6 +134,11 @@ quarkus.cache.caffeine."bootstrap-categories".expire-after-write=60M quarkus.cache.caffeine."bootstrap-companies".maximum-size=64 quarkus.cache.caffeine."bootstrap-companies".expire-after-write=10M +# Installation-wide custom-field definitions (no company scope). Every admin +# definition write explicitly evicts the atom; TTL is only a backstop. +quarkus.cache.caffeine."custom-field-definitions".maximum-size=16 +quarkus.cache.caffeine."custom-field-definitions".expire-after-write=10M + # Hibernate second-level cache. Quarkus enables it by default and already # bundles a suitable (Caffeine/JCache based) implementation with the # hibernate-orm extension, so entities only need @Cacheable. Made explicit diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/CustomFieldCacheTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/CustomFieldCacheTest.java new file mode 100644 index 00000000..3da74656 --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/CustomFieldCacheTest.java @@ -0,0 +1,119 @@ +/* + * 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.CustomField; +import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.service.TicketCustomFieldService; +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.RestAssured; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.util.List; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class CustomFieldCacheTest extends AccessTestSupport { + + private static final String ADMIN = "cf-cache-admin@mnemosyne-systems.ai"; + + @Inject + TicketCustomFieldService ticketCustomFieldService; + + @Test + @TestSecurity(user = ADMIN, roles = { "admin", "support" }) + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-cache-admin") }) + void hookedDefinitionWritesEvictTheCachedDefinitions() { + ensureUser("cf-cache-admin", ADMIN, User.TYPE_ADMIN); + bootstrapNames(); + + createDefinition("CF Cache Hooked", 0); + RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200).body("customFields.name", + Matchers.hasItem("CF Cache Hooked")); + + updateDefinition("CF Cache Hooked", "CF Cache Hooked Renamed", 1); + RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200) + .body("customFields.name", Matchers.hasItem("CF Cache Hooked Renamed")) + .body("customFields.name", Matchers.not(Matchers.hasItem("CF Cache Hooked"))); + + RestAssured.given().redirects().follow(false) + .post("/custom-fields/" + definitionId("CF Cache Hooked Renamed") + "/deactivate").then() + .statusCode(303); + RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200).body("customFields.name", + Matchers.not(Matchers.hasItem("CF Cache Hooked Renamed"))); + } + + @Test + @TestSecurity(user = ADMIN, roles = { "admin", "support" }) + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-cache-admin") }) + void directDefinitionWritesStayCachedUntilInvalidated() { + ensureUser("cf-cache-admin", ADMIN, User.TYPE_ADMIN); + bootstrapNames(); + + Long fieldId = seedDefinitionDirect("CF Cache Bypass"); + RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200).body("customFields.name", + Matchers.not(Matchers.hasItem("CF Cache Bypass"))); + + ticketCustomFieldService.invalidateDefinitions(); + RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200).body("customFields.name", + Matchers.hasItem("CF Cache Bypass")); + + deactivateDefinitionDirect(fieldId); + ticketCustomFieldService.invalidateDefinitions(); + RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200).body("customFields.name", + Matchers.not(Matchers.hasItem("CF Cache Bypass"))); + } + + private List bootstrapNames() { + return RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200).extract().jsonPath() + .getList("customFields.name"); + } + + private void createDefinition(String name, int sortOrder) { + RestAssured.given().redirects().follow(false).multiPart("name", name).multiPart("fieldType", "TEXT") + .multiPart("required", "false").multiPart("description", "") + .multiPart("sortOrder", Integer.toString(sortOrder)).multiPart("active", "true").post("/custom-fields") + .then().statusCode(303); + } + + private void updateDefinition(String name, String newName, int sortOrder) { + RestAssured.given().redirects().follow(false).multiPart("name", newName).multiPart("fieldType", "TEXT") + .multiPart("required", "false").multiPart("description", "") + .multiPart("sortOrder", Integer.toString(sortOrder)).multiPart("active", "true") + .post("/custom-fields/" + definitionId(name)).then().statusCode(303); + } + + private Long definitionId(String name) { + return RestAssured.given().get("/api/custom-fields").then().statusCode(200).extract().jsonPath() + .getLong("items.find { it.name == '" + name + "' }.id"); + } + + @Transactional + Long seedDefinitionDirect(String name) { + CustomField field = new CustomField(); + field.name = name; + field.nameKey = TicketCustomFieldService.normalizeNameKey(name); + field.fieldType = CustomField.TYPE_TEXT; + field.sortOrder = 0; + field.persist(); + return field.id; + } + + @Transactional + void deactivateDefinitionDirect(Long fieldId) { + CustomField field = CustomField.findById(fieldId); + if (field != null) { + field.active = false; + } + } +} diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/CustomFieldTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/CustomFieldTest.java new file mode 100644 index 00000000..a3eaa643 --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/CustomFieldTest.java @@ -0,0 +1,756 @@ +/* + * 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.CompanyEntitlement; +import ai.mnemosyne_systems.model.CustomField; +import ai.mnemosyne_systems.model.CustomFieldOption; +import ai.mnemosyne_systems.model.Category; +import ai.mnemosyne_systems.model.Ticket; +import ai.mnemosyne_systems.model.TicketCustomFieldValue; +import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.model.Version; +import ai.mnemosyne_systems.service.TicketCustomFieldService; +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.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import java.net.URI; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class CustomFieldTest extends AccessTestSupport { + + private static final String ADMIN = "cf-admin@mnemosyne-systems.ai"; + private static final String SUPPORT = "cf-support@mnemosyne-systems.ai"; + private static final String SUPERUSER = "cf-superuser@mnemosyne-systems.ai"; + private static final String TAM = "cf-tam@mnemosyne-systems.ai"; + private static final String USER = "cf-user@mnemosyne-systems.ai"; + private static final String EXTERNAL = "cf-external@mnemosyne-systems.ai"; + + @Inject + TicketCustomFieldService ticketCustomFieldService; + + @Test + @TestSecurity(user = ADMIN, roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void adminManagesDefinitionsAndNonAdminIsRejected() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + Long fieldId = createDefinition("CF Admin Field", "TEXT", false, "Help text", 5, null); + RestAssured.given().get("/api/custom-fields/" + fieldId).then().statusCode(200) + .body("name", Matchers.equalTo("CF Admin Field")).body("fieldType", Matchers.equalTo("TEXT")) + .body("required", Matchers.equalTo(false)).body("active", Matchers.equalTo(true)) + .body("sortOrder", Matchers.equalTo(5)).body("description", Matchers.equalTo("Help text")); + + updateDefinition(fieldId, "CF Admin Field", "TEXT", true, "Updated help", 7, null, true); + RestAssured.given().get("/api/custom-fields/" + fieldId).then().statusCode(200) + .body("required", Matchers.equalTo(true)).body("sortOrder", Matchers.equalTo(7)) + .body("description", Matchers.equalTo("Updated help")); + + deactivateDefinition(fieldId); + RestAssured.given().get("/api/custom-fields/" + fieldId).then().statusCode(200).body("active", + Matchers.equalTo(false)); + updateDefinition(fieldId, "CF Admin Field", "TEXT", true, "Updated help", 7, null, true); + RestAssured.given().get("/api/custom-fields/" + fieldId).then().statusCode(200).body("active", + Matchers.equalTo(true)); + deactivateDefinition(fieldId); + } + + @Test + @TestSecurity(user = ADMIN, roles = { "admin", "support" }) + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void sortOrderIsPersistedAndControlsDisplayOrder() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + Long laterId = createDefinition("CF Sort Later", "TEXT", false, null, 20, null); + Long earlierId = createDefinition("CF Sort Earlier", "TEXT", false, null, 10, null); + + RestAssured.given().get("/api/custom-fields/" + laterId).then().statusCode(200).body("sortOrder", + Matchers.equalTo(20)); + updateDefinition(laterId, "CF Sort Later", "TEXT", false, null, 5, null, true); + RestAssured.given().get("/api/custom-fields/" + laterId).then().statusCode(200).body("sortOrder", + Matchers.equalTo(5)); + + List names = RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200).extract() + .jsonPath().getList("customFields.name", String.class); + Assertions.assertTrue(names.indexOf("CF Sort Later") < names.indexOf("CF Sort Earlier")); + + deactivateDefinition(laterId); + deactivateDefinition(earlierId); + } + + @Test + @TestSecurity(user = SUPPORT, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT), @Claim(key = "sub", value = "cf-support") }) + void nonAdminCannotManageDefinitions() { + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + RestAssured.given().get("/api/custom-fields").then().statusCode(403); + RestAssured.given().redirects().follow(false).multiPart("name", "CF Hacked").multiPart("fieldType", "TEXT") + .post("/custom-fields").then().statusCode(403); + RestAssured.given().redirects().follow(false).post("/custom-fields/1/deactivate").then().statusCode(403); + RestAssured.given().redirects().follow(false).post("/custom-fields/1/delete").then().statusCode(403); + } + + @Test + @TestSecurity(user = ADMIN, roles = { "admin", "support" }) + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void adminDeletesFieldAndItsTicketValues() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + Fixture fixture = fixture("Delete Definition"); + Long fieldId = createDefinition("CF Delete Definition", "SELECT", false, null, 0, "One\nTwo"); + Long ticketId = createTicket(fixture, fields(fieldId, "One")); + + RestAssured.given().redirects().follow(false).post("/custom-fields/" + fieldId + "/delete").then() + .statusCode(303); + RestAssured.given().get("/api/custom-fields/" + fieldId).then().statusCode(404); + RestAssured.given().get("/api/support/tickets/" + ticketId).then().statusCode(200).body("customFields.name", + Matchers.not(Matchers.hasItem("CF Delete Definition"))); + cleanup(fixture, ticketId); + } + + @Test + @TestSecurity(user = ADMIN, roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void duplicateNamesAndTypeChangesAreRejected() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + Long fieldId = createDefinition("CF Duplicate Name", "TEXT", false, null, 0, null); + RestAssured.given().redirects().follow(false).multiPart("name", "cf duplicate name") + .multiPart("fieldType", "TEXT").post("/custom-fields").then().statusCode(400) + .body(Matchers.containsString("already exists")); + RestAssured.given().redirects().follow(false).multiPart("name", "CF Duplicate Name") + .multiPart("fieldType", "NUMBER").post("/custom-fields/" + fieldId).then().statusCode(400) + .body(Matchers.containsString("Field type cannot be changed")); + deactivateDefinition(fieldId); + } + + @Test + @TestSecurity(user = ADMIN, roles = { "admin", "support" }) + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void supportSeesFieldsAndRoundTripsValues() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + Fixture fixture = fixture("Round Trip"); + Long textId = createDefinition("CF Text", "TEXT", false, "Text help", 1, null); + Long numberId = createDefinition("CF Number", "NUMBER", false, "Number help", 2, null); + Long dateId = createDefinition("CF Date", "DATE", false, "Date help", 3, null); + Long selectId = createDefinition("CF Select", "SELECT", false, "Select help", 4, "Alpha\nBeta"); + + RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200).body("customFields.name", + Matchers.hasItems("CF Text", "CF Number", "CF Date", "CF Select")); + + String createValues = fields( + Map.of(textId, "hello", numberId, "123.45", dateId, "2026-10-31", selectId, "Beta")); + Long ticketId = createTicket(fixture, createValues); + RestAssured.given().get("/api/support/tickets/" + ticketId).then().statusCode(200) + .body("editableCustomFields", Matchers.equalTo(true)) + .body("customFields.find { it.name == 'CF Text' }.value", Matchers.equalTo("hello")) + .body("customFields.find { it.name == 'CF Number' }.value", Matchers.equalTo("123.45")) + .body("customFields.find { it.name == 'CF Date' }.value", Matchers.equalTo("2026-10-31")) + .body("customFields.find { it.name == 'CF Select' }.value", Matchers.equalTo("Beta")) + .body("customFields.find { it.name == 'CF Text' }.description", Matchers.equalTo("Text help")); + + String updateValues = fields(Map.of(textId, "updated", numberId, "7", dateId, "2026-11-01", selectId, "Alpha")); + supportUpdate(ticketId, fixture, "Assigned", updateValues); + RestAssured.given().get("/api/support/tickets/" + ticketId).then().statusCode(200) + .body("customFields.find { it.name == 'CF Text' }.value", Matchers.equalTo("updated")) + .body("customFields.find { it.name == 'CF Number' }.value", Matchers.equalTo("7")) + .body("customFields.find { it.name == 'CF Date' }.value", Matchers.equalTo("2026-11-01")) + .body("customFields.find { it.name == 'CF Select' }.value", Matchers.equalTo("Alpha")); + cleanup(fixture, ticketId, textId, numberId, dateId, selectId); + } + + @Test + @TestSecurity(user = ADMIN, roles = { "admin", "support" }) + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void requiredFieldsAreEnforcedOnCreateAndCannotBeCleared() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + Fixture fixture = fixture("Required"); + Long fieldId = createDefinition("CF Required", "TEXT", true, "Required help", 0, null); + + postTicketRaw(fixture, null).then().statusCode(400).body(Matchers.containsString("is required")); + postTicketRaw(fixture, fields(fieldId, " ")).then().statusCode(400) + .body(Matchers.containsString("is required")); + Long ticketId = createTicket(fixture, fields(fieldId, "value")); + supportUpdateRaw(ticketId, fixture, "Assigned", fields(fieldId, " ")).then().statusCode(400) + .body(Matchers.containsString("is required")); + supportUpdate(ticketId, fixture, "Assigned", fields(fieldId, "value2")); + Assertions.assertEquals("value2", detailValue(ticketId, "CF Required")); + + Ticket oldTicket = ensureTicket(fixture.companyId()); + supportUpdateSeededTicket(oldTicket.id, "Assigned", null); + cleanup(fixture, ticketId, fieldId); + } + + @Test + @TestSecurity(user = ADMIN, roles = { "admin", "support" }) + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void invalidValuesAreRejectedWithBadRequest() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + Fixture fixture = fixture("Validation"); + Long numberId = createDefinition("CF Bad Number", "NUMBER", false, null, 1, null); + Long dateId = createDefinition("CF Bad Date", "DATE", false, null, 2, null); + Long selectId = createDefinition("CF Bad Select", "SELECT", false, null, 3, "One\nTwo"); + + postTicketRaw(fixture, "{not-json").then().statusCode(400).body(Matchers.containsString("must be valid JSON")); + postTicketRaw(fixture, "{\"abc\":\"x\"}").then().statusCode(400) + .body(Matchers.containsString("Invalid custom field id")); + postTicketRaw(fixture, fields(999999L, "x")).then().statusCode(400) + .body(Matchers.containsString("Unknown or inactive custom field")); + postTicketRaw(fixture, fields(numberId, "not-a-number")).then().statusCode(400) + .body(Matchers.containsString("must be a number")); + postTicketRaw(fixture, fields(numberId, "123456789012345678901234567890")).then().statusCode(400) + .body(Matchers.containsString("too large")); + postTicketRaw(fixture, fields(numberId, "1.12345678901")).then().statusCode(400) + .body(Matchers.containsString("at most 10 decimal places")); + postTicketRaw(fixture, fields(dateId, "2026-13-40")).then().statusCode(400) + .body(Matchers.containsString("valid date")); + postTicketRaw(fixture, fields(selectId, "Three")).then().statusCode(400) + .body(Matchers.containsString("must be one of")); + cleanup(fixture, null, numberId, dateId, selectId); + } + + @Test + @TestSecurity(user = ADMIN, roles = { "admin", "support" }) + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void textValuesHonourCharacterAndUtf8ByteLimits() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + Fixture fixture = fixture("Text Limits"); + Long fieldId = createDefinition("CF Text Limit", "TEXT", false, null, 0, null); + + String atLimit = "a".repeat(TicketCustomFieldService.TEXT_VALUE_MAX_CHARS); + Long ticketId = createTicket(fixture, fields(fieldId, atLimit)); + Assertions.assertEquals(atLimit, detailValue(ticketId, "CF Text Limit")); + + postTicketRaw(fixture, fields(fieldId, atLimit + "a")).then().statusCode(400) + .body(Matchers.containsString("1000 characters")); + supportUpdate(ticketId, fixture, "Assigned", fields(fieldId, "b".repeat(1000))); + supportUpdateRaw(ticketId, fixture, "Assigned", fields(fieldId, "b".repeat(1001))).then().statusCode(400) + .body(Matchers.containsString("1000 characters")); + + // Send the CJK characters as JSON escapes so the multipart transport stays ASCII; Jackson decodes them to 700 + // characters and 2,100 UTF-8 bytes before validation runs. + String multibyteJson = "{\"" + fieldId + "\":\"" + "\\u4F60".repeat(700) + "\"}"; + postTicketRaw(fixture, multibyteJson).then().statusCode(400).body(Matchers.containsString("2000 UTF-8 bytes")); + cleanup(fixture, ticketId, fieldId); + } + + @Test + @TestSecurity(user = ADMIN, roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void selectFieldsRequireOptionsAndBoundedLabels() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + + RestAssured.given().redirects().follow(false).multiPart("name", "CF Select Empty") + .multiPart("fieldType", "SELECT").multiPart("required", "false").multiPart("description", "") + .multiPart("sortOrder", "0").multiPart("active", "true").multiPart("options", "").post("/custom-fields") + .then().statusCode(400).body(Matchers.containsString("at least one option")); + + String longLabel = "L".repeat(TicketCustomFieldService.TEXT_VALUE_MAX_CHARS + 1); + RestAssured.given().redirects().follow(false).multiPart("name", "CF Select Long Label") + .multiPart("fieldType", "SELECT").multiPart("required", "false").multiPart("description", "") + .multiPart("sortOrder", "0").multiPart("active", "true").multiPart("options", longLabel) + .post("/custom-fields").then().statusCode(400).body(Matchers.containsString("Option label")) + .body(Matchers.containsString("1000 characters")); + + Long fieldId = createDefinition("CF Select Needs Options", "SELECT", false, null, 0, "One\nTwo"); + RestAssured.given().redirects().follow(false).multiPart("name", "CF Select Needs Options") + .multiPart("fieldType", "SELECT").multiPart("required", "false").multiPart("description", "") + .multiPart("sortOrder", "0").multiPart("active", "true").multiPart("options", "") + .post("/custom-fields/" + fieldId).then().statusCode(400) + .body(Matchers.containsString("at least one option")); + deactivateDefinition(fieldId); + } + + @Test + @TestSecurity(user = SUPPORT, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT), @Claim(key = "sub", value = "cf-support") }) + void closedTicketRejectsValueChanges() { + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + Fixture fixture = fixture("Closed"); + Long fieldId = seedDefinition("CF Closed", "TEXT", false, null, 0, List.of()); + Long ticketId = createTicket(fixture, fields(fieldId, "locked")); + supportUpdate(ticketId, fixture, "Closed", fields(fieldId, "locked")); + supportUpdateRaw(ticketId, fixture, "Closed", fields(fieldId, "changed")).then().statusCode(400) + .body(Matchers.containsString("closed ticket")); + RestAssured.given().get("/api/support/tickets/" + ticketId).then().statusCode(200) + .body("editableCustomFields", Matchers.equalTo(false)) + .body("customFields.find { it.name == 'CF Closed' }.value", Matchers.equalTo("locked")); + supportUpdate(ticketId, fixture, "Assigned", fields(fieldId, "reopened")); + Assertions.assertEquals("reopened", detailValue(ticketId, "CF Closed")); + cleanup(fixture, ticketId, fieldId); + } + + @Test + @TestSecurity(user = ADMIN, roles = { "admin", "support" }) + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void deactivatedFieldIsHiddenButValueRemainsVisible() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + Fixture fixture = fixture("Deactivated"); + Long fieldId = createDefinition("CF Deactivated", "TEXT", false, null, 0, null); + Long ticketId = createTicket(fixture, fields(fieldId, "kept")); + + deactivateDefinition(fieldId); + RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200).body("customFields.name", + Matchers.not(Matchers.hasItem("CF Deactivated"))); + supportUpdateRaw(ticketId, fixture, "Assigned", fields(fieldId, "new")).then().statusCode(400) + .body(Matchers.containsString("Unknown or inactive custom field")); + RestAssured.given().get("/api/support/tickets/" + ticketId).then().statusCode(200) + .body("customFields.find { it.name == 'CF Deactivated' }.value", Matchers.equalTo("kept")); + cleanup(fixture, ticketId); + } + + @Test + @TestSecurity(user = ADMIN, roles = { "admin", "support" }) + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN), @Claim(key = "sub", value = "cf-admin") }) + void selectOptionInUseCannotBeRemovedOrRenamed() { + ensureUser("cf-admin", ADMIN, User.TYPE_ADMIN); + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + Fixture fixture = fixture("Select In Use"); + Long fieldId = createDefinition("CF Select In Use", "SELECT", false, null, 0, "Alpha\nBeta"); + Long ticketId = createTicket(fixture, fields(fieldId, "Alpha")); + + RestAssured.given().redirects().follow(false).multiPart("name", "CF Select In Use") + .multiPart("fieldType", "SELECT").multiPart("required", "false").multiPart("description", "") + .multiPart("sortOrder", "0").multiPart("active", "true").multiPart("options", "Beta") + .post("/custom-fields/" + fieldId).then().statusCode(400) + .body(Matchers.containsString("in use and cannot be removed or renamed")); + + updateDefinition(fieldId, "CF Select In Use", "SELECT", false, null, 0, "Alpha\nGamma", true); + RestAssured.given().redirects().follow(false).multiPart("name", "CF Select In Use") + .multiPart("fieldType", "SELECT").multiPart("required", "false").multiPart("description", "") + .multiPart("sortOrder", "0").multiPart("active", "true").multiPart("options", "Alpha2\nGamma") + .post("/custom-fields/" + fieldId).then().statusCode(400) + .body(Matchers.containsString("in use and cannot be removed or renamed")); + + Assertions.assertEquals("Alpha", detailValue(ticketId, "CF Select In Use")); + cleanup(fixture, ticketId, fieldId); + } + + @Test + @TestSecurity(user = SUPPORT, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT), @Claim(key = "sub", value = "cf-support") }) + void ticketDeletionRemovesValues() { + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + Fixture fixture = fixture("Delete"); + Long fieldId = seedDefinition("CF Delete", "TEXT", false, null, 0, List.of()); + Long ticketId = createTicket(fixture, fields(fieldId, "delete me")); + RestAssured.given().redirects().follow(false).post("/tickets/" + ticketId + "/delete").then().statusCode(303); + RestAssured.given().get("/api/support/tickets/" + ticketId).then().statusCode(404); + cleanup(fixture, null, fieldId); + } + + @Test + @TestSecurity(user = SUPERUSER, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER), @Claim(key = "sub", value = "cf-superuser") }) + void superuserSeesValuesReadOnly() { + ensureUser("cf-superuser", SUPERUSER, User.TYPE_SUPERUSER); + RoleFixture fixture = roleFixture("Super", SUPERUSER); + RestAssured.given().get("/api/superuser/tickets/" + fixture.ticketId()).then().statusCode(200) + .body("editableCustomFields", Matchers.equalTo(false)) + .body("customFields.find { it.name == '" + fixture.fieldName() + "' }.value", + Matchers.equalTo("visible")); + var update = RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + .formParam("title", "Role write attempt") + .formParam("customFields", fields(fixture.fieldId(), "hacked")); + Long versionId = ticketVersionId(fixture.ticketId()); + if (versionId != null) { + update.formParam("affectsVersionId", versionId); + } + update.post("/superuser/tickets/" + fixture.ticketId()).then().statusCode(303); + Assertions.assertEquals("visible", + roleDetailValue("/api/superuser/tickets", fixture.ticketId(), fixture.fieldName())); + cleanupRole(fixture); + } + + @Test + @TestSecurity(user = TAM, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM), @Claim(key = "sub", value = "cf-tam") }) + void tamSeesValuesReadOnly() { + ensureUser("cf-tam", TAM, User.TYPE_TAM); + RoleFixture fixture = roleFixture("Tam", TAM); + RestAssured.given().get("/api/user/tickets/" + fixture.ticketId()).then().statusCode(200) + .body("editableCustomFields", Matchers.equalTo(false)) + .body("customFields.find { it.name == '" + fixture.fieldName() + "' }.value", + Matchers.equalTo("visible")); + var update = RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + .formParam("title", "Role write attempt") + .formParam("customFields", fields(fixture.fieldId(), "hacked")); + Long versionId = ticketVersionId(fixture.ticketId()); + if (versionId != null) { + update.formParam("affectsVersionId", versionId); + } + update.post("/user/tickets/" + fixture.ticketId()).then().statusCode(303); + Assertions.assertEquals("visible", + roleDetailValue("/api/user/tickets", fixture.ticketId(), fixture.fieldName())); + cleanupRole(fixture); + } + + @Test + @TestSecurity(user = USER, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER), @Claim(key = "sub", value = "cf-user") }) + void userBootstrapAndCreateIncludeCustomFields() { + ensureUser("cf-user", USER, User.TYPE_USER); + Fixture fixture = fixture("User Create"); + ensureCompanyUsers(fixture.companyId(), USER); + Long fieldId = seedDefinition("CF User Create", "TEXT", false, "User help", 0, List.of()); + + RestAssured.given().get("/api/user/tickets/bootstrap").then().statusCode(200).body("customFields.name", + Matchers.hasItem("CF User Create")); + + Response response = RestAssured.given().redirects().follow(false).contentType(ContentType.MULTIPART) + .multiPart("status", "Open").multiPart("title", "User custom field ticket") + .multiPart("message", "User message").multiPart("companyId", fixture.companyId()) + .multiPart("companyEntitlementId", fixture.entitlementId()) + .multiPart("categoryId", Category.findDefault().id).multiPart("affectsVersionId", fixture.versionId()) + .multiPart("customFields", fields(fieldId, "user value")).post("/user/tickets"); + response.then().statusCode(303); + Long ticketId = ticketIdFrom(response); + + RestAssured.given().get("/api/user/tickets/" + ticketId).then().statusCode(200) + .body("editableCustomFields", Matchers.equalTo(false)) + .body("customFields.find { it.name == 'CF User Create' }.value", Matchers.equalTo("user value")); + cleanup(fixture, ticketId, fieldId); + } + + @Test + @TestSecurity(user = SUPERUSER, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER), @Claim(key = "sub", value = "cf-superuser") }) + void superuserBootstrapAndCreateIncludeCustomFields() { + ensureUser("cf-superuser", SUPERUSER, User.TYPE_SUPERUSER); + Fixture fixture = fixture("Superuser Create"); + ensureCompanyUsers(fixture.companyId(), SUPERUSER); + Long fieldId = seedDefinition("CF Superuser Create", "TEXT", false, "Superuser help", 0, List.of()); + + RestAssured.given().get("/api/superuser/tickets/bootstrap").then().statusCode(200).body("customFields.name", + Matchers.hasItem("CF Superuser Create")); + + Response response = RestAssured.given().redirects().follow(false).contentType(ContentType.MULTIPART) + .multiPart("status", "Open").multiPart("title", "Superuser custom field ticket") + .multiPart("message", "Superuser message").multiPart("companyId", fixture.companyId()) + .multiPart("companyEntitlementId", fixture.entitlementId()) + .multiPart("categoryId", Category.findDefault().id).multiPart("affectsVersionId", fixture.versionId()) + .multiPart("customFields", fields(fieldId, "superuser value")).post("/superuser/tickets"); + response.then().statusCode(303); + Long ticketId = ticketIdFrom(response); + + RestAssured.given().get("/api/superuser/tickets/" + ticketId).then().statusCode(200) + .body("editableCustomFields", Matchers.equalTo(false)) + .body("customFields.find { it.name == 'CF Superuser Create' }.value", + Matchers.equalTo("superuser value")); + cleanup(fixture, ticketId, fieldId); + } + + @Test + @TestSecurity(user = USER, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER), @Claim(key = "sub", value = "cf-user") }) + void userSeesValuesReadOnly() { + ensureUser("cf-user", USER, User.TYPE_USER); + RoleFixture fixture = roleFixture("User", USER); + RestAssured.given().get("/api/user/tickets/" + fixture.ticketId()).then().statusCode(200) + .body("editableCustomFields", Matchers.equalTo(false)) + .body("customFields.find { it.name == '" + fixture.fieldName() + "' }.value", + Matchers.equalTo("visible")); + var update = RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + .formParam("title", "Role write attempt") + .formParam("customFields", fields(fixture.fieldId(), "hacked")); + Long versionId = ticketVersionId(fixture.ticketId()); + if (versionId != null) { + update.formParam("affectsVersionId", versionId); + } + update.post("/user/tickets/" + fixture.ticketId()).then().statusCode(303); + Assertions.assertEquals("visible", + roleDetailValue("/api/user/tickets", fixture.ticketId(), fixture.fieldName())); + cleanupRole(fixture); + } + + @Test + @TestSecurity(user = EXTERNAL, roles = "external") + @JwtSecurity(claims = { @Claim(key = "email", value = EXTERNAL), @Claim(key = "sub", value = "cf-external") }) + void externalHasNoTicketAccess() { + ensureUser("cf-external", EXTERNAL, User.TYPE_EXTERNAL); + RestAssured.given().get("/api/support/tickets/1").then().statusCode(403); + RestAssured.given().get("/api/user/tickets/1").then().statusCode(403); + } + + private Fixture fixture(String suffix) { + Long companyId = ensureCompany("CF Company " + suffix); + ai.mnemosyne_systems.model.Entitlement entitlement = ensureEntitlement("CF Entitlement " + suffix, + "Custom field test"); + CompanyEntitlement entry = ensureCompanyEntitlement(companyId, entitlement); + return new Fixture(companyId, entry.id, firstVersionId(entry.id)); + } + + private RoleFixture roleFixture(String suffix, String memberEmail) { + ensureUser("cf-support", SUPPORT, User.TYPE_SUPPORT); + ensureUser("cf-user", USER, User.TYPE_USER); + Fixture fixture = fixture(suffix); + ensureCompanyUsers(fixture.companyId(), memberEmail); + String fieldName = "CF Role Field " + suffix; + Long fieldId = seedDefinition(fieldName, "TEXT", false, null, 0, List.of()); + Ticket ticket = ensureTicket(fixture.companyId()); + assignRequester(ticket.id, memberEmail); + setCustomValue(ticket.id, fieldId, "visible", null, null); + return new RoleFixture(fixture, ticket.id, fieldId, fieldName); + } + + private void cleanup(Fixture fixture, Long ticketId, Long... fieldIds) { + if (ticketId != null) { + deleteTicketDirect(ticketId); + } + for (Long fieldId : fieldIds) { + deactivateDefinitionDirect(fieldId); + } + ticketCustomFieldService.invalidateDefinitions(); + } + + private void cleanupRole(RoleFixture fixture) { + cleanup(fixture.fixture(), fixture.ticketId(), fixture.fieldId()); + } + + private String detailValue(Long ticketId, String name) { + return RestAssured.given().get("/api/support/tickets/" + ticketId).then().statusCode(200).extract().jsonPath() + .getString("customFields.find { it.name == '" + name + "' }.value"); + } + + private String roleDetailValue(String detailPath, Long ticketId, String name) { + return RestAssured.given().get(detailPath + "/" + ticketId).then().statusCode(200).extract().jsonPath() + .getString("customFields.find { it.name == '" + name + "' }.value"); + } + + private Long createDefinition(String name, String type, boolean required, String description, int sortOrder, + String options) { + var request = RestAssured.given().redirects().follow(false).multiPart("name", name).multiPart("fieldType", type) + .multiPart("required", Boolean.toString(required)) + .multiPart("description", description == null ? "" : description) + .multiPart("sortOrder", Integer.toString(sortOrder)).multiPart("active", "true"); + if (options != null) { + request.multiPart("options", options); + } + request.post("/custom-fields").then().statusCode(303); + return definitionId(name); + } + + private void updateDefinition(Long fieldId, String name, String type, boolean required, String description, + int sortOrder, String options, boolean active) { + var request = RestAssured.given().redirects().follow(false).multiPart("name", name).multiPart("fieldType", type) + .multiPart("required", Boolean.toString(required)) + .multiPart("description", description == null ? "" : description) + .multiPart("sortOrder", Integer.toString(sortOrder)).multiPart("active", Boolean.toString(active)); + if (options != null) { + request.multiPart("options", options); + } + request.post("/custom-fields/" + fieldId).then().statusCode(303); + } + + private void deactivateDefinition(Long fieldId) { + RestAssured.given().redirects().follow(false).post("/custom-fields/" + fieldId + "/deactivate").then() + .statusCode(303); + } + + private Long definitionId(String name) { + return RestAssured.given().get("/api/custom-fields").then().statusCode(200).extract().jsonPath() + .getLong("items.find { it.name == '" + name + "' }.id"); + } + + private Response postTicketRaw(Fixture fixture, String customFieldsJson) { + var request = RestAssured.given().redirects().follow(false).multiPart("status", "Open") + .multiPart("title", "CF ticket").multiPart("message", "Custom field body") + .multiPart("companyId", fixture.companyId()).multiPart("companyEntitlementId", fixture.entitlementId()); + if (fixture.versionId() != null) { + request.multiPart("affectsVersionId", fixture.versionId()); + } + if (customFieldsJson != null) { + request.multiPart("customFields", customFieldsJson); + } + return request.post("/support/tickets"); + } + + private Long createTicket(Fixture fixture, String customFieldsJson) { + Response response = postTicketRaw(fixture, customFieldsJson); + response.then().statusCode(303); + return ticketIdFrom(response); + } + + private Response supportUpdateRaw(Long ticketId, Fixture fixture, String status, String customFieldsJson) { + var request = RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + .formParam("title", "CF updated").formParam("status", status) + .formParam("companyId", fixture.companyId()).formParam("companyEntitlementId", fixture.entitlementId()); + if (fixture.versionId() != null) { + request.formParam("affectsVersionId", fixture.versionId()); + } + if (customFieldsJson != null) { + request.formParam("customFields", customFieldsJson); + } + return request.post("/support/tickets/" + ticketId); + } + + private void supportUpdate(Long ticketId, Fixture fixture, String status, String customFieldsJson) { + supportUpdateRaw(ticketId, fixture, status, customFieldsJson).then().statusCode(303); + } + + private void supportUpdateSeededTicket(Long ticketId, String status, String customFieldsJson) { + var request = RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + .formParam("title", "CF old ticket").formParam("status", status) + .formParam("companyId", ticketCompanyId(ticketId)) + .formParam("companyEntitlementId", ticketEntitlementId(ticketId)); + Long versionId = ticketVersionId(ticketId); + if (versionId != null) { + request.formParam("affectsVersionId", versionId); + } + if (customFieldsJson != null) { + request.formParam("customFields", customFieldsJson); + } + request.post("/support/tickets/" + ticketId).then().statusCode(303); + } + + private Long ticketIdFrom(Response response) { + String location = response.header("Location"); + Assertions.assertNotNull(location); + return Long + .valueOf(URI.create(location).getPath().substring(URI.create(location).getPath().lastIndexOf('/') + 1)); + } + + private String fields(Long fieldId, String value) { + Map values = new LinkedHashMap<>(); + values.put(fieldId, value); + return fields(values); + } + + private String fields(Map values) { + StringBuilder builder = new StringBuilder("{"); + boolean first = true; + for (Map.Entry entry : values.entrySet()) { + if (!first) { + builder.append(','); + } + first = false; + builder.append('"').append(entry.getKey()).append("\":\"").append(escape(entry.getValue())).append('"'); + } + return builder.append('}').toString(); + } + + private String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + @Transactional + Long seedDefinition(String name, String type, boolean required, String description, int sortOrder, + List options) { + CustomField field = new CustomField(); + field.name = name; + field.nameKey = TicketCustomFieldService.normalizeNameKey(name); + field.fieldType = type; + field.required = required; + field.description = description; + field.sortOrder = sortOrder; + field.persist(); + int index = 0; + for (String label : options) { + CustomFieldOption option = new CustomFieldOption(); + option.customField = field; + option.label = label; + option.sortOrder = index++; + option.persist(); + field.options.add(option); + } + ticketCustomFieldService.invalidateDefinitions(); + return field.id; + } + + @Transactional + void assignRequester(Long ticketId, String email) { + Ticket ticket = Ticket.findById(ticketId); + ticket.requester = User.find("email", email).firstResult(); + } + + @Transactional + void setCustomValue(Long ticketId, Long fieldId, String text, BigDecimal number, LocalDate date) { + Ticket ticket = Ticket.findById(ticketId); + CustomField field = CustomField.findById(fieldId); + TicketCustomFieldValue value = TicketCustomFieldValue.find("ticket = ?1 and customField = ?2", ticket, field) + .firstResult(); + if (value == null) { + value = new TicketCustomFieldValue(); + value.ticket = ticket; + value.customField = field; + value.persist(); + ticket.customFieldValues.add(value); + } + value.textValue = text; + value.numberValue = number; + value.dateValue = date; + } + + @Transactional + void deactivateDefinitionDirect(Long fieldId) { + CustomField field = fieldId == null ? null : CustomField.findById(fieldId); + if (field != null) { + field.active = false; + } + } + + @Transactional + void deleteTicketDirect(Long ticketId) { + Ticket ticket = Ticket.findById(ticketId); + if (ticket != null) { + ticket.delete(); + } + } + + @Transactional + Long firstVersionId(Long companyEntitlementId) { + CompanyEntitlement entry = CompanyEntitlement.findById(companyEntitlementId); + Version version = Version.find("entitlement = ?1 order by date asc, id asc", entry.entitlement).firstResult(); + return version == null ? null : version.id; + } + + @Transactional + Long ticketCompanyId(Long ticketId) { + Ticket ticket = Ticket.findById(ticketId); + return ticket.company.id; + } + + @Transactional + Long ticketEntitlementId(Long ticketId) { + Ticket ticket = Ticket.findById(ticketId); + return ticket.companyEntitlement.id; + } + + @Transactional + Long ticketVersionId(Long ticketId) { + Ticket ticket = Ticket.findById(ticketId); + Version version = Version + .find("entitlement = ?1 order by date asc, id asc", ticket.companyEntitlement.entitlement) + .firstResult(); + return version == null ? null : version.id; + } + + private record Fixture(Long companyId, Long entitlementId, Long versionId) { + } + + private record RoleFixture(Fixture fixture, Long ticketId, Long fieldId, String fieldName) { + } +} diff --git a/src/frontend/src/components/tickets/CustomFieldsFields.tsx b/src/frontend/src/components/tickets/CustomFieldsFields.tsx new file mode 100644 index 00000000..dc820507 --- /dev/null +++ b/src/frontend/src/components/tickets/CustomFieldsFields.tsx @@ -0,0 +1,128 @@ +/* + * 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 type { CustomFieldView } from "../../types/domain/customFields"; +import { + EMPTY_CUSTOM_FIELD_SELECT, + TEXT_VALUE_MAX_CHARS, +} from "../../utils/customFields"; +import { Field, FieldDescription, FieldLabel } from "../ui/field"; +import { Input } from "../ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; + +interface CustomFieldsFieldsProps { + fields?: CustomFieldView[]; + values: Record; + onChange: (fieldId: string, value: string) => void; + editable?: boolean; + enforceRequired?: boolean; +} + +export default function CustomFieldsFields({ + fields, + values, + onChange, + editable = false, + enforceRequired = true, +}: CustomFieldsFieldsProps) { + if (!fields || fields.length === 0) { + return null; + } + + return ( + <> + {fields.map((field) => { + const key = String(field.id); + const value = values[key] ?? ""; + const fieldEditable = editable && field.active !== false; + const inputId = `custom-field-${key}`; + const helpId = field.description + ? `custom-field-${key}-description` + : undefined; + return ( + + + {field.name} + {field.required && ( + <> + + (required) + + )} + + {field.description && ( + + {field.description} + + )} + {!fieldEditable ? ( + + ) : field.fieldType === "SELECT" ? ( + + ) : ( + onChange(key, event.target.value)} + aria-describedby={helpId} + maxLength={ + field.fieldType === "TEXT" ? TEXT_VALUE_MAX_CHARS : undefined + } + required={enforceRequired && field.required} + /> + )} + + ); + })} + + ); +} diff --git a/src/frontend/src/pages/CustomFieldFormPage.tsx b/src/frontend/src/pages/CustomFieldFormPage.tsx new file mode 100644 index 00000000..23a00285 --- /dev/null +++ b/src/frontend/src/pages/CustomFieldFormPage.tsx @@ -0,0 +1,433 @@ +/* + * 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 type { FormEvent } from "react"; +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { toast } from "sonner"; +import useJson from "../hooks/useJson"; +import useSubmissionGuard from "../hooks/useSubmissionGuard"; +import useNumberShortcuts from "../hooks/useNumberShortcuts"; +import DataState from "../components/common/DataState"; +import PageHeader from "../components/layout/PageHeader"; +import { postForm, postMultipart } from "../utils/api"; +import { resolvePostRedirectPath } from "../utils/routing"; +import type { AsyncState, FormMode, SessionPageProps } from "../types/app"; +import type { CustomFieldBootstrap, CustomFieldDetail } from "../types/domain"; +import { Button } from "../components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../components/ui/alert-dialog"; +import { Checkbox } from "../components/ui/checkbox"; +import { Field, FieldLabel } from "../components/ui/field"; +import { Input } from "../components/ui/input"; +import { Switch } from "../components/ui/switch"; +import { Textarea } from "../components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../components/ui/select"; + +const DEFAULT_FIELD_TYPES = ["TEXT", "NUMBER", "DATE", "SELECT"]; + +interface CustomFieldFormState { + name: string; + fieldType: string; + required: boolean; + description: string; + optionsText: string; + active: boolean; + sortOrder: number; +} + +interface CustomFieldFormPageProps extends SessionPageProps { + mode: FormMode; +} + +export default function CustomFieldFormPage({ + mode, +}: CustomFieldFormPageProps) { + const navigate = useNavigate(); + const { id } = useParams(); + const isEdit = mode === "edit"; + const customFieldState = useJson( + isEdit && id ? `/api/custom-fields/${id}` : null, + ); + const bootstrapState = useJson( + isEdit ? null : "/api/custom-fields/bootstrap", + ); + const customField = customFieldState.data; + const [formState, setFormState] = useState(null); + const [saveState, setSaveState] = useState({ saving: false, error: "" }); + const submissionGuard = useSubmissionGuard(); + + useNumberShortcuts({ enableFieldJumps: true }); + + const fieldTypes: string[] = + (bootstrapState.data?.fieldTypes?.length || 0) > 0 + ? bootstrapState.data?.fieldTypes || DEFAULT_FIELD_TYPES + : DEFAULT_FIELD_TYPES; + + const updateFormState = ( + field: K, + value: CustomFieldFormState[K], + ) => { + setFormState((current) => + current ? { ...current, [field]: value } : current, + ); + }; + + useEffect(() => { + if (isEdit) { + if (!customField) { + return; + } + setFormState({ + name: customField.name || "", + fieldType: customField.fieldType || "TEXT", + required: Boolean(customField.required), + description: customField.description || "", + optionsText: (customField.options || []).join("\n"), + active: customField.active !== false, + sortOrder: customField.sortOrder ?? 0, + }); + return; + } + if (!bootstrapState.data) { + return; + } + setFormState({ + name: "", + fieldType: fieldTypes[0] || "TEXT", + required: false, + description: "", + optionsText: "", + active: true, + sortOrder: 0, + }); + }, [bootstrapState.data, customField, fieldTypes, isEdit]); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!formState || !submissionGuard.tryEnter()) { + return; + } + const entries: Array<[string, string]> = [ + ["name", formState.name], + ["fieldType", formState.fieldType], + ["required", String(formState.required)], + ["description", formState.description], + ["sortOrder", String(formState.sortOrder)], + ["active", String(formState.active)], + ]; + if (formState.fieldType === "SELECT") { + entries.push(["options", formState.optionsText]); + } + try { + setSaveState({ saving: true, error: "" }); + const response = await postMultipart( + isEdit && id ? `/custom-fields/${id}` : "/custom-fields", + entries, + { headers: { "X-Billetsys-Client": "react" } }, + ); + toast.success( + isEdit + ? "Custom field updated successfully." + : "Custom field created successfully.", + ); + navigate(await resolvePostRedirectPath(response, "/custom-fields")); + } catch (error: unknown) { + setSaveState({ + saving: false, + error: + error instanceof Error + ? error.message + : "Unable to save custom field.", + }); + toast.error( + error instanceof Error ? error.message : "Unable to save custom field.", + ); + return; + } finally { + submissionGuard.exit(); + } + setSaveState({ saving: false, error: "" }); + }; + + const deleteCustomField = async () => { + if (!id || !submissionGuard.tryEnter()) { + return; + } + try { + setSaveState({ saving: true, error: "" }); + const response = await postForm(`/custom-fields/${id}/delete`, [], { + headers: { "X-Billetsys-Client": "react" }, + }); + toast.success("Custom field deleted."); + navigate(await resolvePostRedirectPath(response, "/custom-fields")); + } catch (error: unknown) { + setSaveState({ + saving: false, + error: + error instanceof Error + ? error.message + : "Unable to delete custom field.", + }); + toast.error( + error instanceof Error + ? error.message + : "Unable to delete custom field.", + ); + return; + } finally { + submissionGuard.exit(); + } + setSaveState({ saving: false, error: "" }); + }; + + const state: AsyncState = isEdit + ? customFieldState + : bootstrapState; + + return ( +

+ + {formState && ( + <> + +
+
+ + Name + + updateFormState("name", event.target.value) + } + required + data-shortcut-index="1" + /> + + + + Type + {isEdit ? ( + + ) : ( + + )} + + + + + Required + +
+ + updateFormState("required", checked === true) + } + data-shortcut-index="3" + /> + +
+
+ + {isEdit && ( + + + Active + +
+ + updateFormState("active", checked) + } + /> + + {formState.active + ? "Available in ticket forms" + : "Hidden from ticket forms"} + +
+
+ )} + + + + Description + +