Skip to content
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.openframe.api.dto.ticket;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
Expand All @@ -19,4 +21,8 @@ public class CreateTicketStatusInput {
@NotBlank
@Pattern(regexp = "^#[0-9A-Fa-f]{6}$", message = "color must be a 6-digit hex like #1A2B3C")
private String color;
/** Minutes without activity before a ticket in this column reads as stale. Null keeps the default. */
@Min(value = 1, message = "staleAfterMinutes must be at least 1")
@Max(value = 43200, message = "staleAfterMinutes must be at most 30 days")
private Integer staleAfterMinutes;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.openframe.api.dto.ticket;

import com.openframe.data.document.ticket.TicketStatus;
import com.openframe.data.document.ticket.filter.TicketActivityFilter;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
Expand All @@ -27,6 +28,8 @@ public class TicketFilterInput {
private List<String> tagIds;
/** true keeps only tickets the caller has unread notifications about; false and null do not filter. */
private Boolean hasUnreadNotifications;
@Size(max = 3)
private List<TicketActivityFilter> activity;
//TODO Backward compatibility alias. Remove after FE alignment
@Deprecated
private List<String> labelIds;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.openframe.api.dto.ticket;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
Expand All @@ -19,4 +21,7 @@ public class UpdateTicketStatusInput {
private String name;
@Pattern(regexp = "^#[0-9A-Fa-f]{6}$", message = "color must be a 6-digit hex like #1A2B3C")
private String color;
@Min(value = 1, message = "staleAfterMinutes must be at least 1")
@Max(value = 43200, message = "staleAfterMinutes must be at most 30 days")
private Integer staleAfterMinutes;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.openframe.api.service.ticket;

import com.openframe.data.document.ticket.Ticket;
import com.openframe.data.repository.ticket.TicketRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Optional;

@Service
@RequiredArgsConstructor
@Slf4j
public class TicketActivityService {

private final TicketRepository ticketRepository;

public Optional<Ticket> recordActivity(String ticketId) {
if (ticketId == null) {
return Optional.empty();
}
try {
return ticketRepository.updateLastActivityAt(ticketId, Instant.now());
} catch (Exception e) {
log.warn("Failed to stamp activity for ticket {}", ticketId, e);
return Optional.empty();
}
}

public Optional<Ticket> recordOutboundMessage(String ticketId) {
return stamp(ticketId, Instant.now(), true);
}

public Optional<Ticket> recordClientMessage(String ticketId) {
return stamp(ticketId, Instant.now(), false);
}

private Optional<Ticket> stamp(String ticketId, Instant now, boolean awaitingClient) {
if (ticketId == null) {
return Optional.empty();
}
try {
return ticketRepository.updateActivityAndAwaiting(ticketId, now, awaitingClient ? now : null);
} catch (Exception e) {
log.warn("Failed to stamp activity for ticket {} (awaiting={})", ticketId, awaitingClient, e);
return Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public class TicketService {
private final TicketNumberService ticketNumberService;
private final TicketTagService ticketTagService;
private final TicketIdsForFilter ticketIdsForFilter;
private final TicketStalenessResolver ticketStalenessResolver;
private final MachineRepository machineRepository;
private final OrganizationRepository organizationRepository;
private final UserRepository userRepository;
Expand Down Expand Up @@ -582,6 +583,7 @@ private TicketQueryFilter toQueryFilter(TicketFilterInput filter) {
.statusIds(filter.getStatusIds())
.organizationIds(filter.getOrganizationIds())
.assigneeIds(filter.getAssigneeIds())
.activity(ticketStalenessResolver.resolve(filter.getActivity()))
.build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.openframe.api.service.ticket;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "openframe.tickets.staleness")
public class TicketStalenessProperties {

private int defaultMinutes = 30;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.openframe.api.service.ticket;

import com.openframe.data.document.ticket.TicketStatusDefinition;
import com.openframe.data.document.ticket.filter.TicketActivityCriteria;
import com.openframe.data.document.ticket.filter.TicketActivityFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.springframework.util.CollectionUtils.isEmpty;

@Component
@RequiredArgsConstructor
public class TicketStalenessResolver {

private final TicketStatusService ticketStatusService;
private final TicketStalenessProperties properties;

public int effectiveStaleAfterMinutes(TicketStatusDefinition definition) {
if (definition == null || definition.getStaleAfterMinutes() == null) {
return properties.getDefaultMinutes();
}
return definition.getStaleAfterMinutes();
}

public TicketActivityCriteria resolve(List<TicketActivityFilter> filters) {
if (isEmpty(filters)) {
return null;
}
Instant now = Instant.now();
Map<String, Instant> cutoffs = new HashMap<>();
if (needsThresholds(filters)) {
for (TicketStatusDefinition definition : ticketStatusService.list()) {
int minutes = effectiveStaleAfterMinutes(definition);
cutoffs.put(definition.getId(), now.minus(minutes, ChronoUnit.MINUTES));
}
}
return TicketActivityCriteria.builder()
.filters(List.copyOf(filters))
.staleCutoffByStatusId(cutoffs)
.defaultStaleCutoff(now.minus(properties.getDefaultMinutes(), ChronoUnit.MINUTES))
.build();
}

private boolean needsThresholds(List<TicketActivityFilter> filters) {
return filters.contains(TicketActivityFilter.STALE) || filters.contains(TicketActivityFilter.ACTIVE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public TicketStatusDefinition create(AuthPrincipal principal, @Valid CreateTicke
.name(input.getName())
.color(input.getColor())
.position(nextCustomPosition())
.staleAfterMinutes(input.getStaleAfterMinutes())
.build();

TicketStatusDefinition saved = statusRepository.save(definition);
Expand All @@ -73,6 +74,7 @@ public TicketStatusDefinition update(AuthPrincipal principal, @Valid UpdateTicke

applyName(status, input.getName());
applyColor(status, input.getColor());
applyStaleAfterMinutes(status, input.getStaleAfterMinutes());

return statusRepository.save(status);
}
Expand Down Expand Up @@ -203,6 +205,12 @@ private void applyColor(TicketStatusDefinition status, String color) {
status.setColor(color);
}

private void applyStaleAfterMinutes(TicketStatusDefinition status, Integer staleAfterMinutes) {
if (staleAfterMinutes != null) {
status.setStaleAfterMinutes(staleAfterMinutes);
}
}

private void ensureCustomStatus(TicketStatusDefinition status, TicketStatusOperation operation) {
if (status.getKind().isSystem()) {
throw new SystemTicketStatusModificationException(status.getId(), operation);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class TicketServiceGetTicketsTest {
@Mock private TicketNumberService ticketNumberService;
@Mock private TicketTagService ticketTagService;
@Mock private TicketIdsForFilter ticketIdsForFilter;
@Mock private TicketStalenessResolver ticketStalenessResolver;
@Mock private MachineRepository machineRepository;
@Mock private OrganizationRepository organizationRepository;
@Mock private UserRepository userRepository;
Expand All @@ -54,7 +55,7 @@ class TicketServiceGetTicketsTest {
@BeforeEach
void setUp() {
ticketService = new TicketService(ticketRepository, ticketNumberService, ticketTagService, ticketIdsForFilter,
machineRepository, organizationRepository, userRepository, assignmentService,
ticketStalenessResolver, machineRepository, organizationRepository, userRepository, assignmentService,
ticketOrderCalculationService, ticketLifecycleService, ticketResolverStamp, List.of());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package com.openframe.api.service.ticket;

import com.openframe.data.document.ticket.TicketStatusDefinition;
import com.openframe.data.document.ticket.filter.TicketActivityCriteria;
import com.openframe.data.document.ticket.filter.TicketActivityFilter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class TicketStalenessResolverTest {

private static final int DEFAULT_MINUTES = 120;
private static final int TRIAGE_MINUTES = 30;
private static final String TRIAGE_STATUS_ID = "status-triage";
private static final String PROGRESS_STATUS_ID = "status-progress";

@Mock private TicketStatusService ticketStatusService;

@InjectMocks private TicketStalenessResolver resolver;

private TicketStalenessProperties properties;
private TicketStatusDefinition triageStatus;
private TicketStatusDefinition progressStatus;

@BeforeEach
void setUp() {
properties = new TicketStalenessProperties();
properties.setDefaultMinutes(DEFAULT_MINUTES);
resolver = new TicketStalenessResolver(ticketStatusService, properties);

triageStatus = TicketStatusDefinition.builder()
.id(TRIAGE_STATUS_ID)
.staleAfterMinutes(TRIAGE_MINUTES)
.build();
progressStatus = TicketStatusDefinition.builder()
.id(PROGRESS_STATUS_ID)
.build();
}

@Test
void effectiveStaleAfterMinutes_statusOverridesThreshold_overrideReturned() {
// setup — triage carries its own threshold

// execution
int minutes = resolver.effectiveStaleAfterMinutes(triageStatus);

// verifications
assertThat(minutes).isEqualTo(TRIAGE_MINUTES);
}

@Test
void effectiveStaleAfterMinutes_statusWithoutThreshold_defaultReturned() {
// setup — progress leaves the threshold unset

// execution
int minutes = resolver.effectiveStaleAfterMinutes(progressStatus);

// verifications
assertThat(minutes).isEqualTo(DEFAULT_MINUTES);
}

@ParameterizedTest
@NullSource
void effectiveStaleAfterMinutes_nullStatus_defaultReturned(TicketStatusDefinition definition) {
// setup — a status deleted mid-query resolves to null

// execution
int minutes = resolver.effectiveStaleAfterMinutes(definition);

// verifications
assertThat(minutes).isEqualTo(DEFAULT_MINUTES);
}

@Test
void resolve_noFiltersRequested_nullReturnedAndStatusesNotRead() {
// setup — nothing requested

// execution
TicketActivityCriteria criteria = resolver.resolve(List.of());

// verifications
assertThat(criteria).isNull();
verify(ticketStatusService, never()).list();
}

@ParameterizedTest
@NullSource
void resolve_nullFilters_nullReturned(List<TicketActivityFilter> filters) {
// setup — the filter was omitted entirely

// execution
TicketActivityCriteria criteria = resolver.resolve(filters);

// verifications
assertThat(criteria).isNull();
verify(ticketStatusService, never()).list();
}

@Test
void resolve_staleRequested_cutoffPerStatusResolved() {
// setup
when(ticketStatusService.list()).thenReturn(List.of(triageStatus, progressStatus));
Instant before = Instant.now();

// execution
TicketActivityCriteria criteria = resolver.resolve(List.of(TicketActivityFilter.STALE));

// verifications
assertThat(criteria.staleCutoffByStatusId())
.containsKeys(TRIAGE_STATUS_ID, PROGRESS_STATUS_ID);
assertThat(criteria.staleCutoffByStatusId().get(TRIAGE_STATUS_ID))
.isAfter(criteria.staleCutoffByStatusId().get(PROGRESS_STATUS_ID));
assertThat(criteria.defaultStaleCutoff())
.isBeforeOrEqualTo(before.minus(DEFAULT_MINUTES, ChronoUnit.MINUTES).plusSeconds(1));
}

@Test
void resolve_awaitingExternalOnly_statusThresholdsNotRead() {
// setup — awaiting needs no threshold at all

// execution
TicketActivityCriteria criteria = resolver.resolve(List.of(TicketActivityFilter.AWAITING_EXTERNAL));

// verifications
assertThat(criteria.staleCutoffByStatusId()).isEmpty();
verify(ticketStatusService, never()).list();
}

@Test
void resolve_multipleFiltersRequested_allRetained() {
// setup
when(ticketStatusService.list()).thenReturn(List.of(triageStatus));

// execution
TicketActivityCriteria criteria = resolver.resolve(
List.of(TicketActivityFilter.STALE, TicketActivityFilter.AWAITING_EXTERNAL));

// verifications
assertThat(criteria.filters())
.containsExactlyInAnyOrder(TicketActivityFilter.STALE, TicketActivityFilter.AWAITING_EXTERNAL);
assertThat(criteria.has(TicketActivityFilter.ACTIVE)).isFalse();
}
}
Loading
Loading