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 691e14a72..4e5924b6f 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 @@ -13,78 +13,70 @@ import org.springframework.data.mongodb.core.mapping.Document; import java.time.Instant; -import java.util.List; - -/** - * Primary ticket entity for PSA/Ticketing functionality. - * Owns all metadata; Dialog becomes a simplified child. - * - * Tags are stored via unified TagAssignment (shared tag system). - * Attachments are stored in TicketAttachment collection. - */ + @Data @Builder @NoArgsConstructor @AllArgsConstructor @Document(collection = "tickets") @CompoundIndexes({ - @CompoundIndex(name = "status_created", def = "{'status': 1, 'createdAt': -1}"), - @CompoundIndex(name = "assignee_status", def = "{'assignedTo': 1, 'status': 1}"), - @CompoundIndex(name = "organization_status", def = "{'organizationId': 1, 'status': 1}"), - @CompoundIndex(name = "device_status", def = "{'deviceId': 1, 'status': 1}") + @CompoundIndex(name = "tenant_ticket_number_unique", + def = "{'tenantId': 1, 'ticketNumber': 1}", unique = true), + @CompoundIndex(name = "tenant_status_kind_created", + def = "{'tenantId': 1, 'statusKind': 1, 'createdAt': -1}"), + @CompoundIndex(name = "tenant_assignee_status_kind", + def = "{'tenantId': 1, 'assignedTo': 1, 'statusKind': 1}"), + @CompoundIndex(name = "tenant_org_status_kind", + def = "{'tenantId': 1, 'organizationId': 1, 'statusKind': 1}"), + @CompoundIndex(name = "tenant_device_status_kind", + def = "{'tenantId': 1, 'deviceId': 1, 'statusKind': 1}"), + @CompoundIndex(name = "tenant_status_id", + def = "{'tenantId': 1, 'statusId': 1}") }) public class Ticket { + @Id private String id; - /** - * Human-readable ticket number (e.g., 1001, 1002). - * Auto-incremented per tenant. - */ - @Indexed(unique = true) + @Indexed + private String tenantId; + private Integer ticketNumber; private String title; - /** - * Rich text description (HTML from editor). - */ private String description; @Indexed - private TicketStatus status; + private String statusId; + + @Indexed + private TicketStatusKind statusKind; + + private boolean aiDisabled; @Indexed private TicketCreationSource creationSource; private TicketOwner owner; - /** - * Device (Machine) that this ticket is about. - */ @Indexed private String deviceId; + private String deviceHostname; - /** - * Organization the device belongs to. - */ @Indexed private String organizationId; + private String organizationName; - /** - * Reporter - the end user (future: from Authentic). - * For now, may be null until Authentic integration. - */ private String reporterId; + private String reporterName; - /** - * Assigned technician (User from users collection). - */ @Indexed private String assignedTo; + private String assignedName; @Indexed diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketCreationSource.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketCreationSource.java index 36669e721..d5fe67317 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketCreationSource.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketCreationSource.java @@ -1,11 +1,8 @@ package com.openframe.data.document.ticket; -/** - * How the ticket was created. - * Auto-populated by system based on creation flow. - */ public enum TicketCreationSource { FAE_FORM, FAE_DIALOG, - ADMIN_DASHBOARD + ADMIN_DASHBOARD, + INBOUND_EMAIL } diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatus.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatus.java index 7bc3b0ebb..e69de29bb 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatus.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatus.java @@ -1,29 +0,0 @@ -package com.openframe.data.document.ticket; - -import java.util.Set; - -/** - * Status of a ticket (matches Figma flow). - * Separate from DialogStatus for feature flag isolation. - */ -public enum TicketStatus { - ACTIVE, - TECH_REQUIRED, - ON_HOLD, - RESOLVED, - ARCHIVED; - - public boolean canTransitionTo(TicketStatus target) { - return getAllowedTransitions().contains(target); - } - - public Set getAllowedTransitions() { - return switch (this) { - case ACTIVE -> Set.of(TECH_REQUIRED, ON_HOLD, RESOLVED); - case TECH_REQUIRED -> Set.of(ACTIVE, ON_HOLD, RESOLVED); - case ON_HOLD -> Set.of(ACTIVE, TECH_REQUIRED, RESOLVED); - case RESOLVED -> Set.of(ACTIVE, ARCHIVED); - case ARCHIVED -> Set.of(RESOLVED); - }; - } -} 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 new file mode 100644 index 000000000..d2b6d5306 --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusDefinition.java @@ -0,0 +1,52 @@ +package com.openframe.data.document.ticket; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.CompoundIndexes; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "ticket_statuses") +@CompoundIndexes({ + @CompoundIndex(name = "tenant_name_unique", + def = "{'tenantId': 1, 'name': 1}", unique = true), + @CompoundIndex(name = "tenant_kind_unique", + def = "{'tenantId': 1, 'kind': 1}", unique = true, + partialFilter = "{'kind': {$in: ['AI_ASSISTANCE', 'TECH_REQUIRED', 'RESOLVED', 'ARCHIVED']}}"), + @CompoundIndex(name = "tenant_position", + def = "{'tenantId': 1, 'position': 1}") +}) +public class TicketStatusDefinition { + + @Id + private String id; + + @Indexed + private String tenantId; + + private TicketStatusKind kind; + + private String name; + + private String color; + + private Integer position; + + @CreatedDate + private Instant createdAt; + + @LastModifiedDate + private Instant updatedAt; +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusEvent.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusEvent.java new file mode 100644 index 000000000..3845ba671 --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusEvent.java @@ -0,0 +1,49 @@ +package com.openframe.data.document.ticket; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.CompoundIndexes; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "ticket_status_events") +@CompoundIndexes({ + @CompoundIndex(name = "tenant_ticket_time", + def = "{'tenantId': 1, 'ticketId': 1, 'occurredAt': -1}") +}) +public class TicketStatusEvent { + + @Id + private String id; + + @Indexed + private String tenantId; + + private String ticketId; + + private String fromStatusId; + + private TicketStatusKind fromKind; + + private String toStatusId; + + private TicketStatusKind toKind; + + private String actorType; + + private String actorId; + + private String reason; + + private Instant occurredAt; +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusKind.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusKind.java new file mode 100644 index 000000000..850469ee8 --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/ticket/TicketStatusKind.java @@ -0,0 +1,13 @@ +package com.openframe.data.document.ticket; + +public enum TicketStatusKind { + AI_ASSISTANCE, + TECH_REQUIRED, + RESOLVED, + ARCHIVED, + CUSTOM; + + public boolean isSystem() { + return this != CUSTOM; + } +} 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 0e35985c8..31e2b469f 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 @@ -1,6 +1,6 @@ package com.openframe.data.document.ticket.filter; -import com.openframe.data.document.ticket.TicketStatus; +import com.openframe.data.document.ticket.TicketStatusKind; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -8,16 +8,13 @@ import java.util.List; -/** - * Filter object for ticket queries. - * Follows the same pattern as MachineQueryFilter. - */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class TicketQueryFilter { - private List statuses; + private List statusIds; + private List statusKinds; private List organizationIds; private List assigneeIds; private List labelIds; 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 a886f1de0..34a7e6326 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 @@ -1,7 +1,7 @@ package com.openframe.data.repository.ticket; 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.TicketQueryFilter; import org.springframework.data.mongodb.core.query.Query; @@ -11,27 +11,34 @@ public interface CustomTicketRepository { - default Query buildTicketQuery(TicketQueryFilter filter) { - return buildTicketQuery(filter, null, null, null); - } + Query buildTicketQuery(String tenantId, + TicketQueryFilter filter, + String search, + List restrictToTicketIds, + String ownerMachineId); - Query buildTicketQuery(TicketQueryFilter filter, String search, - List restrictToTicketIds, String ownerMachineId); - - List findTicketsWithCursor(Query query, String cursor, int limit, - String sortField, String sortDirection); + List findTicketsWithCursor(Query query, + String cursor, + int limit, + String sortField, + String sortDirection); long countTickets(Query query); - Map countTicketsByStatus(); + Map countTicketsByStatusKind(String tenantId); + + Map countTicketsByStatusId(String tenantId); - long getTotalCount(); + long getTotalCount(String tenantId); - Optional getAverageResolutionTimeMs(); + Optional getAverageResolutionTimeMs(String tenantId); - int updateStatusBulk(TicketStatus fromStatus, TicketStatus toStatus); + int reassignTicketsToStatus(String tenantId, + String fromStatusId, + String toStatusId, + TicketStatusKind toKind); - void updateTitle(String ticketId, String title); + void updateTitle(String tenantId, String ticketId, String title); boolean isSortableField(String field); 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 68a0870b3..6224b673b 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 @@ -1,7 +1,7 @@ package com.openframe.data.repository.ticket; 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.TicketQueryFilter; import lombok.extern.slf4j.Slf4j; import org.bson.Document; @@ -16,10 +16,14 @@ import java.time.Instant; import java.util.EnumMap; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import static org.springframework.util.CollectionUtils.isEmpty; +import static org.springframework.util.StringUtils.hasText; + @Slf4j public class CustomTicketRepositoryImpl implements CustomTicketRepository { @@ -27,7 +31,9 @@ public class CustomTicketRepositoryImpl implements CustomTicketRepository { private static final String ID_FIELD = "_id"; private static final String DEFAULT_SORT_FIELD = "_id"; - private static final String FIELD_STATUS = "status"; + private static final String FIELD_TENANT_ID = "tenantId"; + private static final String FIELD_STATUS_ID = "statusId"; + private static final String FIELD_STATUS_KIND = "statusKind"; private static final String FIELD_TICKET_NUMBER = "ticketNumber"; private static final String FIELD_ORGANIZATION_ID = "organizationId"; private static final String FIELD_ASSIGNED_TO = "assignedTo"; @@ -49,7 +55,7 @@ public class CustomTicketRepositoryImpl implements CustomTicketRepository { private static final List SORTABLE_FIELDS = List.of( ID_FIELD, FIELD_TICKET_NUMBER, - FIELD_STATUS, + FIELD_STATUS_KIND, FIELD_ORGANIZATION_NAME, FIELD_ASSIGNED_NAME, FIELD_DEVICE_HOSTNAME, @@ -66,35 +72,53 @@ public CustomTicketRepositoryImpl(MongoTemplate mongoTemplate) { } @Override - public Query buildTicketQuery(TicketQueryFilter filter, String search, - List restrictToTicketIds, String ownerMachineId) { + public Query buildTicketQuery(String tenantId, + TicketQueryFilter filter, + String search, + List restrictToTicketIds, + String ownerMachineId) { Query query = new Query(); + query.addCriteria(Criteria.where(FIELD_TENANT_ID).is(tenantId)); - if (filter != null) { - addCriteriaIfNotEmpty(query, FIELD_STATUS, filter.getStatuses()); - addCriteriaIfNotEmpty(query, FIELD_ORGANIZATION_ID, filter.getOrganizationIds()); - addCriteriaIfNotEmpty(query, FIELD_ASSIGNED_TO, filter.getAssigneeIds()); - addCriteriaIfNotEmpty(query, FIELD_DEVICE_ID, filter.getDeviceIds()); - } + applyFilterCriteria(query, filter); + applyRestrictionCriteria(query, restrictToTicketIds); + applyOwnerMachineCriteria(query, ownerMachineId); + applySearchCriteria(query, search); - if (restrictToTicketIds != null) { - if (restrictToTicketIds.isEmpty()) { - query.addCriteria(Criteria.where(ID_FIELD).is(null)); - } else { - query.addCriteria(Criteria.where(ID_FIELD).in(restrictToTicketIds)); - } + return query; + } + + private void applyFilterCriteria(Query query, TicketQueryFilter filter) { + if (filter == null) { + return; } + addCriteriaIfNotEmpty(query, FIELD_STATUS_ID, filter.getStatusIds()); + addCriteriaIfNotEmpty(query, FIELD_STATUS_KIND, filter.getStatusKinds()); + addCriteriaIfNotEmpty(query, FIELD_ORGANIZATION_ID, filter.getOrganizationIds()); + addCriteriaIfNotEmpty(query, FIELD_ASSIGNED_TO, filter.getAssigneeIds()); + addCriteriaIfNotEmpty(query, FIELD_DEVICE_ID, filter.getDeviceIds()); + } - if (ownerMachineId != null) { - query.addCriteria(Criteria.where(FIELD_OWNER_MACHINE_ID).is(ownerMachineId)); + private void applyRestrictionCriteria(Query query, List restrictToTicketIds) { + if (restrictToTicketIds == null) { + return; } + if (restrictToTicketIds.isEmpty()) { + query.addCriteria(Criteria.where(ID_FIELD).is(null)); + } else { + query.addCriteria(Criteria.where(ID_FIELD).in(restrictToTicketIds)); + } + } - applySearchCriteria(query, search); - return query; + private void applyOwnerMachineCriteria(Query query, String ownerMachineId) { + if (!hasText(ownerMachineId)) { + return; + } + query.addCriteria(Criteria.where(FIELD_OWNER_MACHINE_ID).is(ownerMachineId)); } private void applySearchCriteria(Query query, String search) { - if (search == null || search.trim().isEmpty()) { + if (!hasText(search)) { return; } String searchTrimmed = search.trim(); @@ -108,37 +132,49 @@ private void applySearchCriteria(Query query, String search) { } private void addCriteriaIfNotEmpty(Query query, String field, List values) { - if (values != null && !values.isEmpty()) { - query.addCriteria(Criteria.where(field).in(values)); + if (isEmpty(values)) { + return; } + query.addCriteria(Criteria.where(field).in(values)); } @Override - public List findTicketsWithCursor(Query query, String cursor, int limit, - String sortField, String sortDirection) { + public List findTicketsWithCursor(Query query, + String cursor, + int limit, + String sortField, + String sortDirection) { boolean isDesc = SORT_DESC.equalsIgnoreCase(sortDirection); Sort.Direction mongoSortDirection = isDesc ? Sort.Direction.DESC : Sort.Direction.ASC; - if (cursor != null && !cursor.trim().isEmpty()) { - try { - ObjectId cursorId = new ObjectId(cursor); - applyCursorCriteria(query, cursorId, sortField, isDesc); - } catch (IllegalArgumentException ex) { - log.warn("Invalid ObjectId cursor format: {}", cursor); - } - } + applyCursorIfPresent(query, cursor, sortField, isDesc); query.limit(limit); + applySortOrder(query, sortField, mongoSortDirection); - if (ID_FIELD.equals(sortField)) { - query.with(Sort.by(mongoSortDirection, ID_FIELD)); - } else { - query.with(Sort.by( - Sort.Order.by(sortField).with(mongoSortDirection), - Sort.Order.by(ID_FIELD).with(mongoSortDirection) - )); + return mongoTemplate.find(query, Ticket.class); + } + + private void applyCursorIfPresent(Query query, String cursor, String sortField, boolean isDesc) { + if (!hasText(cursor)) { + return; + } + try { + ObjectId cursorId = new ObjectId(cursor); + applyCursorCriteria(query, cursorId, sortField, isDesc); + } catch (IllegalArgumentException ex) { + log.warn("Invalid ObjectId cursor format: {}", cursor); } + } - return mongoTemplate.find(query, Ticket.class); + private void applySortOrder(Query query, String sortField, Sort.Direction direction) { + if (ID_FIELD.equals(sortField)) { + query.with(Sort.by(direction, ID_FIELD)); + return; + } + query.with(Sort.by( + Sort.Order.by(sortField).with(direction), + Sort.Order.by(ID_FIELD).with(direction) + )); } private void applyCursorCriteria(Query query, ObjectId cursorId, String sortField, boolean isDesc) { @@ -181,7 +217,7 @@ private Criteria buildIdCriteria(ObjectId cursorId, boolean isDesc) { private Object getSortFieldValue(Ticket ticket, String sortField) { return switch (sortField) { case FIELD_TICKET_NUMBER -> ticket.getTicketNumber(); - case FIELD_STATUS -> ticket.getStatus() != null ? ticket.getStatus().name() : null; + case FIELD_STATUS_KIND -> ticket.getStatusKind() != null ? ticket.getStatusKind().name() : null; case FIELD_ORGANIZATION_NAME -> ticket.getOrganizationName(); case FIELD_ASSIGNED_NAME -> ticket.getAssignedName(); case FIELD_DEVICE_HOSTNAME -> ticket.getDeviceHostname(); @@ -199,40 +235,65 @@ public long countTickets(Query query) { } @Override - public Map countTicketsByStatus() { + public Map countTicketsByStatusKind(String tenantId) { Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group(FIELD_STATUS).count().as(AGG_COUNT), - Aggregation.project(AGG_COUNT).and(ID_FIELD).as(FIELD_STATUS) + Aggregation.match(Criteria.where(FIELD_TENANT_ID).is(tenantId)), + Aggregation.group(FIELD_STATUS_KIND).count().as(AGG_COUNT), + Aggregation.project(AGG_COUNT).and(ID_FIELD).as(FIELD_STATUS_KIND) ); AggregationResults results = mongoTemplate.aggregate( aggregation, Ticket.class, Document.class); - Map statusCounts = new EnumMap<>(TicketStatus.class); + Map kindCounts = new EnumMap<>(TicketStatusKind.class); for (Document doc : results.getMappedResults()) { - String statusStr = doc.getString(FIELD_STATUS); - if (statusStr != null) { - try { - TicketStatus status = TicketStatus.valueOf(statusStr); - statusCounts.put(status, doc.getInteger(AGG_COUNT).longValue()); - } catch (IllegalArgumentException e) { - log.warn("Unknown ticket status: {}", statusStr); - } + String kindStr = doc.getString(FIELD_STATUS_KIND); + if (!hasText(kindStr)) { + continue; + } + try { + TicketStatusKind kind = TicketStatusKind.valueOf(kindStr); + kindCounts.put(kind, doc.getInteger(AGG_COUNT).longValue()); + } catch (IllegalArgumentException e) { + log.warn("Unknown ticket status kind: {}", kindStr); } } + return kindCounts; + } + + @Override + public Map countTicketsByStatusId(String tenantId) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(FIELD_TENANT_ID).is(tenantId)), + Aggregation.group(FIELD_STATUS_ID).count().as(AGG_COUNT), + Aggregation.project(AGG_COUNT).and(ID_FIELD).as(FIELD_STATUS_ID) + ); + + AggregationResults results = mongoTemplate.aggregate( + aggregation, Ticket.class, Document.class); + Map statusCounts = new HashMap<>(); + for (Document doc : results.getMappedResults()) { + String statusId = doc.getString(FIELD_STATUS_ID); + if (!hasText(statusId)) { + continue; + } + statusCounts.put(statusId, doc.getInteger(AGG_COUNT).longValue()); + } return statusCounts; } @Override - public long getTotalCount() { - return mongoTemplate.count(new Query(), Ticket.class); + public long getTotalCount(String tenantId) { + Query query = new Query(Criteria.where(FIELD_TENANT_ID).is(tenantId)); + return mongoTemplate.count(query, Ticket.class); } @Override - public Optional getAverageResolutionTimeMs() { + public Optional getAverageResolutionTimeMs(String tenantId) { Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(FIELD_RESOLVED_AT).ne(null) + Aggregation.match(Criteria.where(FIELD_TENANT_ID).is(tenantId) + .and(FIELD_RESOLVED_AT).ne(null) .and(FIELD_CREATED_AT).ne(null)), Aggregation.project() .andExpression(FIELD_RESOLVED_AT + " - " + FIELD_CREATED_AT).as(AGG_RESOLUTION_TIME), @@ -243,30 +304,35 @@ public Optional getAverageResolutionTimeMs() { aggregation, Ticket.class, Document.class); Document result = results.getUniqueMappedResult(); - if (result != null && result.get(AGG_AVG_RESOLUTION_TIME) != null) { - Number avg = (Number) result.get(AGG_AVG_RESOLUTION_TIME); - return Optional.of(avg.longValue()); + if (result == null || result.get(AGG_AVG_RESOLUTION_TIME) == null) { + return Optional.empty(); } - - return Optional.empty(); + Number avg = (Number) result.get(AGG_AVG_RESOLUTION_TIME); + return Optional.of(avg.longValue()); } @Override - public int updateStatusBulk(TicketStatus fromStatus, TicketStatus toStatus) { - Query query = new Query(Criteria.where(FIELD_STATUS).is(fromStatus)); + public int reassignTicketsToStatus(String tenantId, + String fromStatusId, + String toStatusId, + TicketStatusKind toKind) { + Query query = new Query(Criteria.where(FIELD_TENANT_ID).is(tenantId) + .and(FIELD_STATUS_ID).is(fromStatusId)); Update update = new Update() - .set(FIELD_STATUS, toStatus) + .set(FIELD_STATUS_ID, toStatusId) + .set(FIELD_STATUS_KIND, toKind) .set(FIELD_UPDATED_AT, Instant.now()); long modifiedCount = mongoTemplate.updateMulti(query, update, Ticket.class).getModifiedCount(); - log.debug("Bulk status update: {} -> {}, modified: {}", fromStatus, toStatus, modifiedCount); - + log.info("Reassigned {} tickets from statusId {} to {} for tenant {}", + modifiedCount, fromStatusId, toStatusId, tenantId); return (int) modifiedCount; } @Override - public void updateTitle(String ticketId, String title) { - Query query = new Query(Criteria.where(ID_FIELD).is(ticketId)); + public void updateTitle(String tenantId, String ticketId, String title) { + Query query = new Query(Criteria.where(FIELD_TENANT_ID).is(tenantId) + .and(ID_FIELD).is(ticketId)); Update update = new Update() .set(FIELD_TITLE, title) .set(FIELD_UPDATED_AT, Instant.now()); @@ -276,7 +342,7 @@ public void updateTitle(String ticketId, String title) { @Override public boolean isSortableField(String field) { - return field != null && SORTABLE_FIELDS.contains(field.trim()); + return hasText(field) && SORTABLE_FIELDS.contains(field.trim()); } @Override diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/TicketRepository.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/TicketRepository.java index ae312a49c..25a21bd40 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/TicketRepository.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/TicketRepository.java @@ -1,7 +1,7 @@ package com.openframe.data.repository.ticket; import com.openframe.data.document.ticket.Ticket; -import com.openframe.data.document.ticket.TicketStatus; +import com.openframe.data.document.ticket.TicketStatusKind; import org.springframework.data.mongodb.repository.Aggregation; import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.Query; @@ -13,46 +13,52 @@ @Repository public interface TicketRepository extends MongoRepository, CustomTicketRepository { - Optional findByTicketNumber(Integer ticketNumber); + Optional findByTenantIdAndTicketNumber(String tenantId, Integer ticketNumber); - List findByStatus(TicketStatus status); + Optional findByTenantIdAndId(String tenantId, String id); - List findByOrganizationId(String organizationId); + List findByTenantIdAndStatusKind(String tenantId, TicketStatusKind statusKind); - List findByAssignedTo(String assignedTo); + List findByTenantIdAndStatusId(String tenantId, String statusId); - List findByDeviceId(String deviceId); + List findByTenantIdAndOrganizationId(String tenantId, String organizationId); - List findByIdIn(List ids); + List findByTenantIdAndAssignedTo(String tenantId, String assignedTo); - @Query("{ '_id': ?0, 'owner.machineId': ?1 }") - Optional findByIdAndOwnerMachineId(String id, String machineId); + List findByTenantIdAndDeviceId(String tenantId, String deviceId); + + List findByTenantIdAndIdIn(String tenantId, List ids); + + long countByTenantIdAndStatusId(String tenantId, String statusId); + + @Query("{ 'tenantId': ?0, '_id': ?1, 'owner.machineId': ?2 }") + Optional findByTenantIdAndIdAndOwnerMachineId(String tenantId, String id, String machineId); @Aggregation(pipeline = { - "{ $match: { 'status': ?0, 'order': { $ne: null } } }", + "{ $match: { 'tenantId': ?0, 'statusId': ?1, 'order': { $ne: null } } }", "{ $sort: { 'order': 1 } }", "{ $limit: 1 }" }) - Optional findFirstInColumn(TicketStatus status); + Optional findFirstInColumn(String tenantId, String statusId); @Aggregation(pipeline = { - "{ $match: { 'status': ?0, 'order': { $ne: null } } }", + "{ $match: { 'tenantId': ?0, 'statusId': ?1, 'order': { $ne: null } } }", "{ $sort: { 'order': -1 } }", "{ $limit: 1 }" }) - Optional findLastInColumn(TicketStatus status); + Optional findLastInColumn(String tenantId, String statusId); @Aggregation(pipeline = { - "{ $match: { 'status': ?0, 'order': { $gt: ?1 } } }", + "{ $match: { 'tenantId': ?0, 'statusId': ?1, 'order': { $gt: ?2 } } }", "{ $sort: { 'order': 1 } }", "{ $limit: 1 }" }) - Optional findFirstAfter(TicketStatus status, String order); + Optional findFirstAfter(String tenantId, String statusId, String order); @Aggregation(pipeline = { - "{ $match: { 'status': ?0, 'order': { $lt: ?1 } } }", + "{ $match: { 'tenantId': ?0, 'statusId': ?1, 'order': { $lt: ?2 } } }", "{ $sort: { 'order': -1 } }", "{ $limit: 1 }" }) - Optional findFirstBefore(TicketStatus status, String order); + Optional findFirstBefore(String tenantId, String statusId, String order); } diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/TicketStatusDefinitionRepository.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/TicketStatusDefinitionRepository.java new file mode 100644 index 000000000..97d6d7a51 --- /dev/null +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/TicketStatusDefinitionRepository.java @@ -0,0 +1,27 @@ +package com.openframe.data.repository.ticket; + +import com.openframe.data.document.ticket.TicketStatusDefinition; +import com.openframe.data.document.ticket.TicketStatusKind; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TicketStatusDefinitionRepository extends MongoRepository { + + List findByTenantIdOrderByPositionAsc(String tenantId); + + Optional findByTenantIdAndId(String tenantId, String id); + + Optional findByTenantIdAndKind(String tenantId, TicketStatusKind kind); + + Optional findByTenantIdAndName(String tenantId, String name); + + List findByTenantIdAndKindOrderByPositionAsc(String tenantId, TicketStatusKind kind); + + long countByTenantIdAndKind(String tenantId, TicketStatusKind kind); + + boolean existsByTenantIdAndName(String tenantId, String name); +} diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/TicketStatusEventRepository.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/TicketStatusEventRepository.java new file mode 100644 index 000000000..a1b7d2a4f --- /dev/null +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/ticket/TicketStatusEventRepository.java @@ -0,0 +1,13 @@ +package com.openframe.data.repository.ticket; + +import com.openframe.data.document.ticket.TicketStatusEvent; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface TicketStatusEventRepository extends MongoRepository { + + List findByTenantIdAndTicketIdOrderByOccurredAtDesc(String tenantId, String ticketId); +} diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/seed/ticket/TicketStatusSeedCatalog.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/seed/ticket/TicketStatusSeedCatalog.java new file mode 100644 index 000000000..5aa6ee908 --- /dev/null +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/seed/ticket/TicketStatusSeedCatalog.java @@ -0,0 +1,78 @@ +package com.openframe.data.seed.ticket; + +import com.openframe.data.document.ticket.TicketStatusDefinition; + +import java.util.List; + +import static com.openframe.data.document.ticket.TicketStatusKind.AI_ASSISTANCE; +import static com.openframe.data.document.ticket.TicketStatusKind.ARCHIVED; +import static com.openframe.data.document.ticket.TicketStatusKind.CUSTOM; +import static com.openframe.data.document.ticket.TicketStatusKind.RESOLVED; +import static com.openframe.data.document.ticket.TicketStatusKind.TECH_REQUIRED; + +public final class TicketStatusSeedCatalog { + + public static final String NAME_AI_ASSISTANCE = "AI Assistance"; + public static final String NAME_TECH_REQUIRED = "Tech Required"; + public static final String NAME_RESOLVED = "Resolved"; + public static final String NAME_ARCHIVED = "Archived"; + public static final String NAME_ON_HOLD = "On Hold"; + + public static final String COLOR_AI_ASSISTANCE = "#7C3AED"; + public static final String COLOR_TECH_REQUIRED = "#F59E0B"; + public static final String COLOR_RESOLVED = "#10B981"; + public static final String COLOR_ARCHIVED = "#6B7280"; + public static final String COLOR_ON_HOLD_DEFAULT = "#FACC15"; + + public static final int POSITION_AI_ASSISTANCE = -1000; + public static final int POSITION_TECH_REQUIRED = -100; + public static final int POSITION_FIRST_CUSTOM = 0; + public static final int POSITION_RESOLVED = 1000; + public static final int POSITION_ARCHIVED = 2000; + + private TicketStatusSeedCatalog() { + } + + public static List systemStatuses(String tenantId) { + return List.of( + TicketStatusDefinition.builder() + .tenantId(tenantId) + .kind(AI_ASSISTANCE) + .name(NAME_AI_ASSISTANCE) + .color(COLOR_AI_ASSISTANCE) + .position(POSITION_AI_ASSISTANCE) + .build(), + TicketStatusDefinition.builder() + .tenantId(tenantId) + .kind(TECH_REQUIRED) + .name(NAME_TECH_REQUIRED) + .color(COLOR_TECH_REQUIRED) + .position(POSITION_TECH_REQUIRED) + .build(), + TicketStatusDefinition.builder() + .tenantId(tenantId) + .kind(RESOLVED) + .name(NAME_RESOLVED) + .color(COLOR_RESOLVED) + .position(POSITION_RESOLVED) + .build(), + TicketStatusDefinition.builder() + .tenantId(tenantId) + .kind(ARCHIVED) + .name(NAME_ARCHIVED) + .color(COLOR_ARCHIVED) + .position(POSITION_ARCHIVED) + .build() + ); + } + + public static TicketStatusDefinition onHoldCustom(String tenantId) { + return TicketStatusDefinition.builder() + .tenantId(tenantId) + .kind(CUSTOM) + .name(NAME_ON_HOLD) + .color(COLOR_ON_HOLD_DEFAULT) + .position(POSITION_FIRST_CUSTOM) + .build(); + } +} diff --git a/openframe-exception/src/main/java/com/openframe/core/exception/ErrorCode.java b/openframe-exception/src/main/java/com/openframe/core/exception/ErrorCode.java index 20bb00975..2c4b6aa5c 100644 --- a/openframe-exception/src/main/java/com/openframe/core/exception/ErrorCode.java +++ b/openframe-exception/src/main/java/com/openframe/core/exception/ErrorCode.java @@ -37,6 +37,16 @@ public enum ErrorCode { TAG_NOT_FOUND("TAG_NOT_FOUND", 404), TAG_ALREADY_EXISTS("TAG_ALREADY_EXISTS", 409), + // Ticket specific + TICKET_NOT_FOUND("TICKET_NOT_FOUND", 404), + TICKET_INVALID_TRANSITION("TICKET_INVALID_TRANSITION", 409), + TICKET_AI_DISABLED("TICKET_AI_DISABLED", 409), + TICKET_STATUS_NOT_FOUND("TICKET_STATUS_NOT_FOUND", 404), + TICKET_STATUS_IN_USE("TICKET_STATUS_IN_USE", 409), + TICKET_STATUS_DUPLICATE_NAME("TICKET_STATUS_DUPLICATE_NAME", 409), + TICKET_STATUS_SYSTEM_PROTECTED("TICKET_STATUS_SYSTEM_PROTECTED", 403), + TICKET_STATUS_LIMIT("TICKET_STATUS_LIMIT", 409), + // Authorization specific USER_ACTIVE_IN_ANOTHER_TENANT("USER_IS_ACTIVE_IN_ANOTHER_TENANT", 409), diff --git a/openframe-management-service-core/src/main/java/com/openframe/management/migration/BackfillTicketOrdersChangeUnit.java b/openframe-management-service-core/src/main/java/com/openframe/management/migration/BackfillTicketOrdersChangeUnit.java index 8ed64dd49..2c376bf16 100644 --- a/openframe-management-service-core/src/main/java/com/openframe/management/migration/BackfillTicketOrdersChangeUnit.java +++ b/openframe-management-service-core/src/main/java/com/openframe/management/migration/BackfillTicketOrdersChangeUnit.java @@ -2,7 +2,7 @@ import com.github.pravin.raha.lexorank4j.LexoRank; import com.openframe.data.document.ticket.Ticket; -import com.openframe.data.document.ticket.TicketStatus; +import com.openframe.data.service.TenantIdProvider; import io.mongock.api.annotations.ChangeUnit; import io.mongock.api.annotations.Execution; import io.mongock.api.annotations.RollbackExecution; @@ -15,43 +15,64 @@ import java.util.List; +import static org.springframework.util.StringUtils.hasText; + @Slf4j -@ChangeUnit(id = "backfill-ticket-orders", order = "002", author = "openframe") +@ChangeUnit(id = "backfill-ticket-orders", order = "003", author = "openframe") public class BackfillTicketOrdersChangeUnit { - private static final String STATUS_FIELD = "status"; - private static final String ORDER_FIELD = "order"; - private static final String CREATED_AT_FIELD = "createdAt"; - private static final String ID_FIELD = "_id"; + private static final String FIELD_ID = "_id"; + private static final String FIELD_TENANT_ID = "tenantId"; + private static final String FIELD_STATUS_ID = "statusId"; + private static final String FIELD_ORDER = "order"; + private static final String FIELD_CREATED_AT = "createdAt"; @Execution - public void execution(MongoTemplate mongoTemplate) { - for (TicketStatus status : TicketStatus.values()) { - backfillColumn(mongoTemplate, status); + public void execution(MongoTemplate mongoTemplate, TenantIdProvider tenantIdProvider) { + String tenantId = tenantIdProvider.getTenantId(); + if (!hasText(tenantId)) { + log.warn("Backfill ticket orders: tenantId not available; skipping"); + return; } + log.info("Backfill ticket orders: tenant {}", tenantId); + List statusIds = findDistinctStatusIds(mongoTemplate, tenantId); + statusIds.forEach(statusId -> backfillColumn(mongoTemplate, tenantId, statusId)); + log.info("Backfill ticket orders: complete for tenant {}", tenantId); } @RollbackExecution public void rollback() { } - private void backfillColumn(MongoTemplate mongoTemplate, TicketStatus status) { - Query query = new Query(Criteria.where(STATUS_FIELD).is(status) - .and(ORDER_FIELD).is(null)); - query.with(Sort.by(Sort.Direction.DESC, CREATED_AT_FIELD)); + private List findDistinctStatusIds(MongoTemplate mongoTemplate, String tenantId) { + Query query = new Query(Criteria.where(FIELD_TENANT_ID).is(tenantId)); + return mongoTemplate.findDistinct(query, FIELD_STATUS_ID, Ticket.class, String.class); + } + + private void backfillColumn(MongoTemplate mongoTemplate, String tenantId, String statusId) { + Query query = new Query(Criteria.where(FIELD_TENANT_ID).is(tenantId) + .and(FIELD_STATUS_ID).is(statusId) + .and(FIELD_ORDER).is(null)); + query.with(Sort.by(Sort.Direction.DESC, FIELD_CREATED_AT)); List tickets = mongoTemplate.find(query, Ticket.class); + if (tickets.isEmpty()) { + return; + } + LexoRank rank = LexoRank.middle(); for (Ticket ticket : tickets) { - assignOrder(mongoTemplate, ticket.getId(), rank.format()); + assignOrder(mongoTemplate, tenantId, ticket.getId(), rank.format()); rank = rank.genNext(); } - log.info("Backfilled order on {} tickets in status {}", tickets.size(), status); + log.info("Backfill ticket orders: tenant {} statusId {} → {} ticket(s)", + tenantId, statusId, tickets.size()); } - private void assignOrder(MongoTemplate mongoTemplate, String ticketId, String order) { - Query byId = new Query(Criteria.where(ID_FIELD).is(ticketId)); - Update update = new Update().set(ORDER_FIELD, order); + private void assignOrder(MongoTemplate mongoTemplate, String tenantId, String ticketId, String order) { + Query byId = new Query(Criteria.where(FIELD_TENANT_ID).is(tenantId) + .and(FIELD_ID).is(ticketId)); + Update update = new Update().set(FIELD_ORDER, order); mongoTemplate.updateFirst(byId, update, Ticket.class); } } diff --git a/pom.xml b/pom.xml index baa31e0f7..7f732ef7e 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ https://github.com/flamingo-stack/openframe-oss-lib - 5.65.3 + 5.70.1 21 1.18.30 4.2.0