diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/CreateTicketStatusInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/CreateTicketStatusInput.java index fd28135fac..c2f284efa3 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/CreateTicketStatusInput.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/CreateTicketStatusInput.java @@ -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; @@ -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; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/TicketFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/TicketFilterInput.java index 7997c79fa8..b673765e0e 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/TicketFilterInput.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/TicketFilterInput.java @@ -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; @@ -27,6 +28,8 @@ public class TicketFilterInput { private List 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 activity; //TODO Backward compatibility alias. Remove after FE alignment @Deprecated private List labelIds; diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/UpdateTicketStatusInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/UpdateTicketStatusInput.java index 3a561ab8e6..191e7bacfc 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/UpdateTicketStatusInput.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/ticket/UpdateTicketStatusInput.java @@ -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; @@ -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; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketActivityService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketActivityService.java new file mode 100644 index 0000000000..ce2d08933e --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketActivityService.java @@ -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 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 recordOutboundMessage(String ticketId) { + return stamp(ticketId, Instant.now(), true); + } + + public Optional recordClientMessage(String ticketId) { + return stamp(ticketId, Instant.now(), false); + } + + private Optional 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(); + } + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketService.java index cdb4325830..8b1522aa52 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketService.java @@ -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; @@ -582,6 +583,7 @@ private TicketQueryFilter toQueryFilter(TicketFilterInput filter) { .statusIds(filter.getStatusIds()) .organizationIds(filter.getOrganizationIds()) .assigneeIds(filter.getAssigneeIds()) + .activity(ticketStalenessResolver.resolve(filter.getActivity())) .build(); } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketStalenessProperties.java b/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketStalenessProperties.java new file mode 100644 index 0000000000..97afddf2fe --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketStalenessProperties.java @@ -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; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketStalenessResolver.java b/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketStalenessResolver.java new file mode 100644 index 0000000000..3b137d90a3 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketStalenessResolver.java @@ -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 filters) { + if (isEmpty(filters)) { + return null; + } + Instant now = Instant.now(); + Map 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 filters) { + return filters.contains(TicketActivityFilter.STALE) || filters.contains(TicketActivityFilter.ACTIVE); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketStatusService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketStatusService.java index 94daff60c0..c2b0c3dc6f 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketStatusService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/ticket/TicketStatusService.java @@ -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); @@ -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); } @@ -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); diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/ticket/TicketServiceGetTicketsTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/ticket/TicketServiceGetTicketsTest.java index aa4fa75fb0..293a4f3166 100644 --- a/openframe-api-lib/src/test/java/com/openframe/api/service/ticket/TicketServiceGetTicketsTest.java +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/ticket/TicketServiceGetTicketsTest.java @@ -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; @@ -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()); } diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/ticket/TicketStalenessResolverTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/ticket/TicketStalenessResolverTest.java new file mode 100644 index 0000000000..58c6ac56d0 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/ticket/TicketStalenessResolverTest.java @@ -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 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(); + } +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/Ticket.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/Ticket.java index 2a5f4cdfa8..44c54f29e2 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/Ticket.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/Ticket.java @@ -29,7 +29,9 @@ // TODO(lifecycle-rollout): drop legacy status_order index after `status` field removal @CompoundIndex(name = "status_order", def = "{'status': 1, 'order': 1}"), @CompoundIndex(name = "status_kind", def = "{'statusKind': 1}"), - @CompoundIndex(name = "status_id_order", def = "{'statusId': 1, 'order': 1}") + @CompoundIndex(name = "status_id_order", def = "{'statusId': 1, 'order': 1}"), + // Board activity filter: per-column staleness scan. Never edit a live index def in place. + @CompoundIndex(name = "tenant_status_activity", def = "{'tenantId': 1, 'statusId': 1, 'lastActivityAt': 1}") }) public class Ticket implements TenantScoped { @Id @@ -62,10 +64,17 @@ public class Ticket implements TenantScoped { @LastModifiedDate private Instant updatedAt; private Instant resolvedAt; + private Instant lastActivityAt; + private Instant awaitingClientSince; private TicketResolver resolvedBy; private String resolvedById; private String resolvedByName; private Integer reopenCount; + + public Instant effectiveLastActivityAt() { + return lastActivityAt != null ? lastActivityAt : createdAt; + } + public boolean isAiDisabled() { return statusKind != null && statusKind != TicketStatusKind.AI_ASSISTANCE; } diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketActivityState.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketActivityState.java new file mode 100644 index 0000000000..68d9c97f72 --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketActivityState.java @@ -0,0 +1,8 @@ +package com.openframe.data.document.ticket; + +public enum TicketActivityState { + + AI_WORKING, + AWAITING_EXTERNAL, + IDLE +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusDefinition.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusDefinition.java index b1d3f370bc..0f085883fd 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusDefinition.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusDefinition.java @@ -28,6 +28,7 @@ public class TicketStatusDefinition implements TenantScoped { private String name; private String color; private String position; + private Integer staleAfterMinutes; @CreatedDate private Instant createdAt; @LastModifiedDate diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/filter/TicketActivityCriteria.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/filter/TicketActivityCriteria.java new file mode 100644 index 0000000000..523cc8d931 --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/filter/TicketActivityCriteria.java @@ -0,0 +1,22 @@ +package com.openframe.data.document.ticket.filter; + +import lombok.Builder; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@Builder +public record TicketActivityCriteria( + List filters, + Map staleCutoffByStatusId, + Instant defaultStaleCutoff) { + + public boolean isEmpty() { + return filters == null || filters.isEmpty(); + } + + public boolean has(TicketActivityFilter filter) { + return filters != null && filters.contains(filter); + } +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/filter/TicketActivityFilter.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/filter/TicketActivityFilter.java new file mode 100644 index 0000000000..150c50d98f --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/filter/TicketActivityFilter.java @@ -0,0 +1,7 @@ +package com.openframe.data.document.ticket.filter; + +public enum TicketActivityFilter { + ACTIVE, + STALE, + AWAITING_EXTERNAL +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/filter/TicketQueryFilter.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/filter/TicketQueryFilter.java index e3dea1d534..abe598b961 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/filter/TicketQueryFilter.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/filter/TicketQueryFilter.java @@ -37,4 +37,5 @@ public class TicketQueryFilter { private List creationSources; private Instant createdAtFrom; private Instant createdAtTo; + private TicketActivityCriteria activity; } diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/CustomTicketRepository.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/CustomTicketRepository.java index 21925eb0dc..b668300826 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/CustomTicketRepository.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/CustomTicketRepository.java @@ -6,6 +6,7 @@ import com.openframe.data.document.ticket.filter.TicketQueryFilter; import org.springframework.data.mongodb.core.query.Query; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; @@ -42,6 +43,16 @@ List findTicketsWithCursor(Query query, String cursor, int limit, void updateTitle(String ticketId, String title); + /** + * Stamps the ticket's last activity and returns the stamped document, so a caller that has to + * broadcast the change gets {@code statusId} and {@code awaitingClientSince} from the same round + * trip. Deliberately does not touch {@code updatedAt}. + */ + Optional updateLastActivityAt(String ticketId, Instant lastActivityAt); + + /** Stamps activity and sets (or, with a null {@code awaitingSince}, clears) the client wait. */ + Optional updateActivityAndAwaiting(String ticketId, Instant lastActivityAt, Instant awaitingSince); + boolean isSortableField(String field); String getDefaultSortField(); diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/CustomTicketRepositoryImpl.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/CustomTicketRepositoryImpl.java index 7143658c3e..516ea13ee9 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/CustomTicketRepositoryImpl.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/CustomTicketRepositoryImpl.java @@ -3,6 +3,8 @@ import com.openframe.data.document.ticket.Ticket; import com.openframe.data.document.ticket.TicketStatus; import com.openframe.data.document.ticket.TicketStatusKind; +import com.openframe.data.document.ticket.filter.TicketActivityCriteria; +import com.openframe.data.document.ticket.filter.TicketActivityFilter; import com.openframe.data.document.ticket.filter.TicketQueryFilter; import com.openframe.data.mongo.TenantAwareMongoTemplate; import com.openframe.data.repository.TenantAwareRepositorySupport; @@ -11,6 +13,7 @@ import org.bson.types.ObjectId; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.aggregation.AggregationResults; import org.springframework.data.mongodb.core.query.BasicQuery; @@ -20,8 +23,10 @@ import java.time.Instant; import java.util.ArrayList; +import java.util.ArrayList; import java.util.EnumMap; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -55,6 +60,8 @@ public class CustomTicketRepositoryImpl extends TenantAwareRepositorySupport imp private static final String FIELD_UPDATED_AT = "updatedAt"; private static final String FIELD_RESOLVED_AT = "resolvedAt"; private static final String FIELD_ORDER = "order"; + private static final String FIELD_LAST_ACTIVITY_AT = "lastActivityAt"; + private static final String FIELD_AWAITING_CLIENT_SINCE = "awaitingClientSince"; private static final String CASE_INSENSITIVE = "i"; @@ -73,6 +80,7 @@ public class CustomTicketRepositoryImpl extends TenantAwareRepositorySupport imp FIELD_CREATED_AT, FIELD_UPDATED_AT, FIELD_RESOLVED_AT, + FIELD_LAST_ACTIVITY_AT, FIELD_ORDER ); @@ -95,6 +103,7 @@ public Query buildTicketQuery(TicketQueryFilter filter, String search, addCriteriaIfNotEmpty(query, FIELD_DEVICE_ID, filter.getDeviceIds()); addCriteriaIfNotEmpty(query, FIELD_CREATION_SOURCE, filter.getCreationSources()); applyCreatedAtRange(query, filter.getCreatedAtFrom(), filter.getCreatedAtTo()); + applyActivityCriteria(query, filter.getActivity()); } if (restrictToTicketIds != null) { @@ -176,6 +185,59 @@ private void applyCreatedAtRange(Query query, Instant from, Instant to) { query.addCriteria(criteria); } + private void applyActivityCriteria(Query query, TicketActivityCriteria activity) { + if (activity == null || activity.isEmpty()) { + return; + } + List clauses = new ArrayList<>(); + if (activity.has(TicketActivityFilter.STALE)) { + clauses.addAll(thresholdClauses(activity, true)); + } + if (activity.has(TicketActivityFilter.ACTIVE)) { + clauses.addAll(thresholdClauses(activity, false)); + } + if (activity.has(TicketActivityFilter.AWAITING_EXTERNAL)) { + clauses.add(Criteria.where(FIELD_AWAITING_CLIENT_SINCE).ne(null)); + } + if (clauses.isEmpty()) { + return; + } + query.addCriteria(new Criteria().orOperator(clauses.toArray(new Criteria[0]))); + } + + private List thresholdClauses(TicketActivityCriteria activity, boolean stale) { + List clauses = new ArrayList<>(); + Map byStatus = activity.staleCutoffByStatusId(); + + if (byStatus != null && !byStatus.isEmpty()) { + Map> statusesByCutoff = new LinkedHashMap<>(); + byStatus.forEach((statusId, cutoff) -> + statusesByCutoff.computeIfAbsent(cutoff, key -> new ArrayList<>()).add(statusId)); + statusesByCutoff.forEach((cutoff, statusIds) -> clauses.add(new Criteria().andOperator( + Criteria.where(FIELD_STATUS_ID).in(statusIds), + activityCutoffCriteria(cutoff, stale)))); + } + + Instant fallback = activity.defaultStaleCutoff(); + if (fallback != null) { + Criteria unmappedStatus = byStatus == null || byStatus.isEmpty() + ? new Criteria() + : Criteria.where(FIELD_STATUS_ID).nin(byStatus.keySet()); + clauses.add(new Criteria().andOperator(unmappedStatus, activityCutoffCriteria(fallback, stale))); + } + return clauses; + } + + private Criteria activityCutoffCriteria(Instant cutoff, boolean stale) { + Criteria onActivity = stale + ? Criteria.where(FIELD_LAST_ACTIVITY_AT).lt(cutoff) + : Criteria.where(FIELD_LAST_ACTIVITY_AT).gte(cutoff); + Criteria onCreatedAt = new Criteria().andOperator( + Criteria.where(FIELD_LAST_ACTIVITY_AT).is(null), + stale ? Criteria.where(FIELD_CREATED_AT).lt(cutoff) : Criteria.where(FIELD_CREATED_AT).gte(cutoff)); + return new Criteria().orOperator(onActivity, onCreatedAt); + } + @Override public List findTicketsWithCursor(Query query, String cursor, int limit, String sortField, String sortDirection) { @@ -267,6 +329,7 @@ private Object getSortFieldValue(Ticket ticket, String sortField) { case FIELD_CREATED_AT -> ticket.getCreatedAt(); case FIELD_UPDATED_AT -> ticket.getUpdatedAt(); case FIELD_RESOLVED_AT -> ticket.getResolvedAt(); + case FIELD_LAST_ACTIVITY_AT -> ticket.getLastActivityAt(); case FIELD_ORDER -> ticket.getOrder(); default -> null; }; @@ -423,6 +486,39 @@ public void updateTitle(String ticketId, String title) { mongoTemplate.updateFirst(query, update, Ticket.class); } + /** + * Stamps activity with a targeted update. Never a full save: the document carries + * {@code @LastModifiedDate updatedAt}, which clients read as "the status moved", and saving the + * whole ticket on every message would silently redefine that field. + */ + @Override + public Optional updateLastActivityAt(String ticketId, Instant lastActivityAt) { + Query query = new Query(Criteria.where(ID_FIELD).is(ticketId)); + Update update = new Update().set(FIELD_LAST_ACTIVITY_AT, lastActivityAt); + return stamp(query, update); + } + + /** + * Stamps activity and marks the ticket as waiting on the client in one write. + * {@code awaitingSince} of null clears the wait — the client has answered. + */ + @Override + public Optional updateActivityAndAwaiting(String ticketId, Instant lastActivityAt, Instant awaitingSince) { + Query query = new Query(Criteria.where(ID_FIELD).is(ticketId)); + Update update = new Update().set(FIELD_LAST_ACTIVITY_AT, lastActivityAt); + if (awaitingSince == null) { + update.unset(FIELD_AWAITING_CLIENT_SINCE); + } else { + update.set(FIELD_AWAITING_CLIENT_SINCE, awaitingSince); + } + return stamp(query, update); + } + + private Optional stamp(Query query, Update update) { + FindAndModifyOptions options = FindAndModifyOptions.options().returnNew(true); + return Optional.ofNullable(mongoTemplate.findAndModify(query, update, options, Ticket.class)); + } + @Override public boolean isSortableField(String field) { return field != null && SORTABLE_FIELDS.contains(field.trim()); diff --git a/openframe-frontend-core/src/components/features/board/types.ts b/openframe-frontend-core/src/components/features/board/types.ts index 84f2c87b52..63b2dc5367 100644 --- a/openframe-frontend-core/src/components/features/board/types.ts +++ b/openframe-frontend-core/src/components/features/board/types.ts @@ -35,7 +35,7 @@ export interface BoardTicketActivity { /** * Overrides the built-in label for the kind. Required in practice for * 'stale', whose label carries the computed duration ("No activity for - * 2 hours") that only the consumer can know — and tick over time. + * 30 minutes") that only the consumer can know — and tick over time. */ label?: string; } diff --git a/openframe-frontend-core/src/components/shared/doc-search/__tests__/map-doc-search-results.test.ts b/openframe-frontend-core/src/components/shared/doc-search/__tests__/map-doc-search-results.test.ts index 92c6e2339f..ea8216e24a 100644 --- a/openframe-frontend-core/src/components/shared/doc-search/__tests__/map-doc-search-results.test.ts +++ b/openframe-frontend-core/src/components/shared/doc-search/__tests__/map-doc-search-results.test.ts @@ -19,17 +19,16 @@ const FOLDED_TABLES = [ 'financial-cash-flow', ]; -const row = (sourceRepo: string, n: number): DocSearchResult => - ({ - path: `${sourceRepo}/${n}`, - name: `Row ${n}`, - snippet: '', - type: 'file', - matchType: 'content', - documentType: 'cap_table', - sourceRepo, - entityId: `${n}`, - }) as DocSearchResult; +const row = (sourceRepo: string, n: number): DocSearchResult => ({ + path: `${sourceRepo}/${n}`, + name: `Row ${n}`, + snippet: '', + type: 'file', + matchType: 'content', + documentType: 'cap_table', + sourceRepo, + entityId: `${n}`, +}); describe('mapDocSearchResults', () => { it.each(FOLDED_TABLES)('titles the folded %s row with its shared label and count', table => { diff --git a/openframe-frontend-core/src/stories/TicketCard.stories.tsx b/openframe-frontend-core/src/stories/TicketCard.stories.tsx index 7805142131..e13c1f57f5 100644 --- a/openframe-frontend-core/src/stories/TicketCard.stories.tsx +++ b/openframe-frontend-core/src/stories/TicketCard.stories.tsx @@ -166,7 +166,7 @@ export const ActivityIndicators: Story = { { kind: 'ai-working' }, { kind: 'user-typing' }, { kind: 'waiting-external' }, - { kind: 'stale', label: 'No activity for 2 hours' }, + { kind: 'stale', label: 'No activity for 30 minutes' }, ]; return (
diff --git a/openframe-management-service-core/src/main/java/com/openframe/management/migration/BackfillTicketLastActivityAtChangeUnit.java b/openframe-management-service-core/src/main/java/com/openframe/management/migration/BackfillTicketLastActivityAtChangeUnit.java new file mode 100644 index 0000000000..0a764e808c --- /dev/null +++ b/openframe-management-service-core/src/main/java/com/openframe/management/migration/BackfillTicketLastActivityAtChangeUnit.java @@ -0,0 +1,84 @@ +package com.openframe.management.migration; + +import com.mongodb.client.MongoCursor; +import com.mongodb.client.result.UpdateResult; +import com.openframe.data.document.ticket.Ticket; +import com.openframe.data.service.TenantIdProvider; +import io.mongock.api.annotations.ChangeUnit; +import io.mongock.api.annotations.Execution; +import io.mongock.api.annotations.RollbackExecution; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +import java.time.Instant; +import java.util.Date; +import java.util.List; + +@Slf4j +@ChangeUnit(id = "backfill-ticket-last-activity-at", order = "012", author = "openframe") +public class BackfillTicketLastActivityAtChangeUnit { + + private static final String TICKETS_COLLECTION = "tickets"; + private static final String DIALOGS_COLLECTION = "dialogs"; + private static final String LAST_ACTIVITY_AT = "lastActivityAt"; + private static final String LAST_MESSAGE_AT = "lastMessageAt"; + private static final String TICKET_ID = "ticketId"; + private static final String TENANT_ID = "tenantId"; + private static final String ID_FIELD = "_id"; + + @Execution + public void execution(MongoTemplate mongoTemplate, TenantIdProvider tenantIdProvider) { + String tenantId = tenantIdProvider.getTenantId(); + seedFromTicketTimestamps(mongoTemplate, tenantId); + advanceFromDialogs(mongoTemplate, tenantId); + } + + @RollbackExecution + public void rollback() { + } + + private void seedFromTicketTimestamps(MongoTemplate mongoTemplate, String tenantId) { + Document filter = new Document(TENANT_ID, tenantId) + .append(LAST_ACTIVITY_AT, new Document("$exists", false)); + List pipeline = List.of(new Document("$set", + new Document(LAST_ACTIVITY_AT, new Document("$ifNull", List.of("$updatedAt", "$createdAt"))))); + + UpdateResult result = mongoTemplate.getCollection(TICKETS_COLLECTION).updateMany(filter, pipeline); + log.info("Seeded {} from ticket timestamps on {} document(s)", LAST_ACTIVITY_AT, result.getModifiedCount()); + } + + private void advanceFromDialogs(MongoTemplate mongoTemplate, String tenantId) { + Document filter = new Document(TENANT_ID, tenantId) + .append(TICKET_ID, new Document("$ne", null)) + .append(LAST_MESSAGE_AT, new Document("$ne", null)); + Document projection = new Document(TICKET_ID, 1).append(LAST_MESSAGE_AT, 1); + + long advanced = 0; + try (MongoCursor cursor = mongoTemplate.getCollection(DIALOGS_COLLECTION) + .find(filter).projection(projection).iterator()) { + while (cursor.hasNext()) { + Document dialog = cursor.next(); + advanced += advanceTicket(mongoTemplate, dialog); + } + } + log.info("Advanced {} from dialog activity on {} ticket(s)", LAST_ACTIVITY_AT, advanced); + } + + private long advanceTicket(MongoTemplate mongoTemplate, Document dialog) { + String ticketId = dialog.getString(TICKET_ID); + Date lastMessageAt = dialog.getDate(LAST_MESSAGE_AT); + if (ticketId == null || lastMessageAt == null) { + return 0; + } + Instant activity = lastMessageAt.toInstant(); + // Only ever moves the stamp forward, which is what makes a re-run a no-op. + Query query = new Query(Criteria.where(ID_FIELD).is(ticketId) + .and(LAST_ACTIVITY_AT).lt(activity)); + Update update = new Update().set(LAST_ACTIVITY_AT, activity); + return mongoTemplate.updateFirst(query, update, Ticket.class).getModifiedCount(); + } +}