Feature: Desktop view - Add recently closed list (instead of current recently viewed implementation).

Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
This commit is contained in:
Dusan Vuckovic
2025-11-07 11:35:42 +00:00
parent bfd2f5befc
commit cfeee1ef5d
57 changed files with 936 additions and 421 deletions

View File

@@ -19,9 +19,9 @@ import SubscriptionHandler from '#shared/server/apollo/handler/SubscriptionHandl
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonSectionCollapse from '#desktop/components/CommonSectionCollapse/CommonSectionCollapse.vue'
import QuickSearchResultList from '#desktop/components/Search/QuickSearch/QuickSearchResultList/QuickSearchResultList.vue'
import { useUserCurrentRecentViewResetMutation } from '#desktop/entities/user/current/graphql/mutations/userCurrentRecentViewReset.api.ts'
import { useUserCurrentRecentViewListQuery } from '#desktop/entities/user/current/graphql/queries/userCurrentRecentViewList.api.ts'
import { useUserCurrentRecentViewUpdatesSubscription } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentRecentViewUpdates.api.ts'
import { useUserCurrentRecentCloseResetMutation } from '#desktop/entities/user/current/graphql/mutations/userCurrentRecentCloseReset.api.ts'
import { useUserCurrentRecentCloseListQuery } from '#desktop/entities/user/current/graphql/queries/userCurrentRecentCloseList.api.ts'
import { useUserCurrentRecentCloseUpdatesSubscription } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentRecentCloseUpdates.api.ts'
import { searchPluginByName } from '../plugins/index.ts'
@@ -50,25 +50,25 @@ const { isTouchDevice } = useTouchDevice()
const { ADD_RECENT_SEARCH_DEBOUNCE_TIME, recentSearches, addSearch, clearSearches, removeSearch } =
useRecentSearches()
const recentViewListQuery = new QueryHandler(
useUserCurrentRecentViewListQuery({
const recentCloseListQuery = new QueryHandler(
useUserCurrentRecentCloseListQuery({
limit: 10,
}),
)
const recentViewListQueryResult = recentViewListQuery.result()
const recentCloseListQueryResult = recentCloseListQuery.result()
const recentViewListItems = computed(
() => recentViewListQueryResult.value?.userCurrentRecentViewList ?? [],
const recentCloseListItems = computed(
() => recentCloseListQueryResult.value?.userCurrentRecentCloseList ?? [],
)
const recentViewUpdatesSubscription = new SubscriptionHandler(
useUserCurrentRecentViewUpdatesSubscription(),
const recentCloseUpdatesSubscription = new SubscriptionHandler(
useUserCurrentRecentCloseUpdatesSubscription(),
)
recentViewUpdatesSubscription.onResult(({ data }) => {
if (data?.userCurrentRecentViewUpdates) {
recentViewListQuery.refetch()
recentCloseUpdatesSubscription.onResult(({ data }) => {
if (data?.userCurrentRecentCloseUpdates) {
recentCloseListQuery.refetch()
}
})
@@ -113,24 +113,24 @@ const confirmClearRecentSearches = async () => {
})
}
const recentViewResetMutation = new MutationHandler(useUserCurrentRecentViewResetMutation())
const recentCloseResetMutation = new MutationHandler(useUserCurrentRecentCloseResetMutation())
const confirmClearRecentViewed = async () => {
const confirmClearRecentlyClosed = async () => {
const confirmed = await waitForConfirmation(
__('Are you sure? Your recently viewed items will get lost.'),
__('Are you sure? Your recently closed items will get lost.'),
{ fullscreen: true },
)
if (!confirmed) return
recentViewResetMutation.send().then(() => {
recentCloseResetMutation.send().then(() => {
// Evict the list cache immediately, before the subscription update comes in.
getApolloClient().cache.evict({ fieldName: 'userCurrentRecentViewList' })
getApolloClient().cache.evict({ fieldName: 'userCurrentRecentCloseList' })
notify({
id: 'recent-viewed-cleared',
id: 'recently-closed-cleared',
type: NotificationTypes.Success,
message: __('Recently viewed items were cleared successfully.'),
message: __('Recently closed items were cleared successfully.'),
})
})
}
@@ -146,9 +146,9 @@ const { resetQuickSearchInputField } = useQuickSearchInput()
:debounce-time="DEBOUNCE_TIME"
/>
<template v-else-if="recentSearches.length > 0 || recentViewListItems.length > 0">
<template v-else-if="recentSearches.length || recentCloseListItems.length">
<CommonSectionCollapse
v-if="recentSearches.length > 0"
v-if="recentSearches.length"
id="page-recent-searches"
:title="__('Recent searches')"
:no-header="collapsed"
@@ -199,33 +199,34 @@ const { resetQuickSearchInputField } = useQuickSearchInput()
</CommonSectionCollapse>
<CommonSectionCollapse
v-if="recentViewListItems.length > 0"
id="page-recently-viewed"
:title="__('Recently viewed')"
v-if="recentCloseListItems.length"
id="page-recently-closed"
:title="__('Recently closed')"
:no-header="collapsed"
no-collapse
>
<template #default="{ headerId }">
<nav :aria-labelledby="headerId">
<ul class="m-0 flex flex-col gap-1 p-0">
<li v-for="item in recentViewListItems" :key="item.id" class="relative">
<li v-for="item in recentCloseListItems" :key="item.id" class="relative">
<component
:is="searchPluginByName[item.__typename!].quickSearchComponent"
:item="item"
mode="recently-viewed"
mode="recently-closed"
@click="resetQuickSearchInputField"
/>
</li>
</ul>
<div class="mt-2 mb-1 flex justify-end">
<CommonLink link="#" size="small" @click="confirmClearRecentViewed">
{{ $t('Clear recently viewed') }}
<CommonLink link="#" size="small" @click="confirmClearRecentlyClosed">
{{ $t('Clear recently closed') }}
</CommonLink>
</div>
</nav>
</template>
</CommonSectionCollapse>
</template>
<CommonLabel v-else>
{{ $t('Start typing e.g. the name of a ticket, an organization or a user.') }}
</CommonLabel>

View File

@@ -7,12 +7,17 @@ import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { waitForNextTick } from '#tests/support/utils.ts'
import { useRecentSearches } from '#shared/composables/useRecentSearches.ts'
import { EnumTicketStateColorCode, type Organization } from '#shared/graphql/types.ts'
import {
EnumTicketStateColorCode,
type Organization,
type Ticket,
type User,
} from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import { mockUserCurrentRecentViewResetMutation } from '#desktop/entities/user/current/graphql/mutations/userCurrentRecentViewReset.mocks.ts'
import { mockUserCurrentRecentViewListQuery } from '#desktop/entities/user/current/graphql/queries/userCurrentRecentViewList.mocks.ts'
import { getUserCurrentRecentViewUpdatesSubscriptionHandler } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentRecentViewUpdates.mocks.ts'
import { mockUserCurrentRecentCloseResetMutation } from '#desktop/entities/user/current/graphql/mutations/userCurrentRecentCloseReset.mocks.ts'
import { mockUserCurrentRecentCloseListQuery } from '#desktop/entities/user/current/graphql/queries/userCurrentRecentCloseList.mocks.ts'
import { getUserCurrentRecentCloseUpdatesSubscriptionHandler } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentRecentCloseUpdates.mocks.ts'
import { mockQuickSearchQuery } from '../../graphql/queries/quickSearch.mocks.ts'
import QuickSearch from '../QuickSearch.vue'
@@ -43,8 +48,8 @@ describe('QuickSearch', () => {
})
describe('default state', () => {
it('shows empty state message when no searches or recently viewed items exist', async () => {
mockUserCurrentRecentViewListQuery({ userCurrentRecentViewList: [] })
it('shows empty state message when no searches or recently closed items exist', async () => {
mockUserCurrentRecentCloseListQuery({ userCurrentRecentCloseList: [] })
const wrapper = await renderQuickSearch()
@@ -58,7 +63,7 @@ describe('QuickSearch', () => {
describe('recent searches', () => {
beforeEach(() => {
mockUserCurrentRecentViewListQuery({ userCurrentRecentViewList: [] })
mockUserCurrentRecentCloseListQuery({ userCurrentRecentCloseList: [] })
})
it('displays recent searches when added', async () => {
@@ -115,39 +120,39 @@ describe('QuickSearch', () => {
})
})
describe('recently viewed items', () => {
const recentlyViewedItems = [
describe('recently closed items', () => {
const recentlyClosedItems = [
{
__typename: 'Ticket',
id: convertToGraphQLId('Ticket', 2),
title: 'Ticket 1',
number: '1',
stateColorCode: EnumTicketStateColorCode.Open,
},
} as Ticket,
{
__typename: 'User',
id: convertToGraphQLId('User', 2),
internalId: 2,
fullname: 'User 1',
},
} as User,
{
__typename: 'Organization',
id: convertToGraphQLId('Organization', 2),
internalId: 2,
name: 'Organization 1',
},
} as Organization,
]
it('displays recently viewed items', async () => {
it('displays recently closed items', async () => {
mockPermissions(['ticket.agent'])
mockUserCurrentRecentViewListQuery({
userCurrentRecentViewList: recentlyViewedItems as Organization[],
mockUserCurrentRecentCloseListQuery({
userCurrentRecentCloseList: recentlyClosedItems,
})
const wrapper = await renderQuickSearch()
expect(
wrapper.getByRole('heading', { level: 3, name: 'Recently viewed' }),
wrapper.getByRole('heading', { level: 3, name: 'Recently closed' }),
).toBeInTheDocument()
expect(wrapper.getByRole('link', { name: 'check-circle-noTicket 1' })).toBeInTheDocument()
@@ -157,24 +162,24 @@ describe('QuickSearch', () => {
expect(wrapper.getByRole('link', { name: 'Organization 1' })).toBeInTheDocument()
})
it('allows clearing all recently viewed items', async () => {
mockUserCurrentRecentViewListQuery({
userCurrentRecentViewList: recentlyViewedItems as Organization[],
it('allows clearing all recently closed items', async () => {
mockUserCurrentRecentCloseListQuery({
userCurrentRecentCloseList: recentlyClosedItems,
})
mockUserCurrentRecentViewResetMutation({
userCurrentRecentViewReset: { success: true },
mockUserCurrentRecentCloseResetMutation({
userCurrentRecentCloseReset: { success: true },
})
const wrapper = await renderQuickSearch()
expect(wrapper.getByRole('link', { name: 'Clear recently viewed' })).toBeInTheDocument()
expect(wrapper.getByRole('link', { name: 'Clear recently closed' })).toBeInTheDocument()
mockUserCurrentRecentViewListQuery({ userCurrentRecentViewList: [] })
mockUserCurrentRecentCloseListQuery({ userCurrentRecentCloseList: [] })
await getUserCurrentRecentViewUpdatesSubscriptionHandler().trigger({
userCurrentRecentViewUpdates: {
recentViewsUpdated: true,
await getUserCurrentRecentCloseUpdatesSubscriptionHandler().trigger({
userCurrentRecentCloseUpdates: {
recentCloseUpdated: true,
},
})
@@ -184,7 +189,7 @@ describe('QuickSearch', () => {
wrapper.getByText('Start typing e.g. the name of a ticket, an organization or a user.'),
).toBeInTheDocument()
expect(wrapper.queryByRole('link', { name: 'Clear recently viewed' })).not.toBeInTheDocument()
expect(wrapper.queryByRole('link', { name: 'Clear recently closed' })).not.toBeInTheDocument()
})
it('display search results when typing', async () => {

View File

@@ -14,7 +14,7 @@ import type { QuickSearchPluginProps } from '../../types.ts'
const props = defineProps<QuickSearchPluginProps>()
const itemLabel = computed(() => {
if (props.mode === 'recently-viewed') {
if (props.mode === 'recently-closed') {
return (props.item as TicketById).title
}

View File

@@ -22,7 +22,7 @@ export type SearchPlugin = {
export interface QuickSearchPluginProps {
item: ObjectLike
mode: 'recently-viewed' | 'quick-search-results'
mode: 'recently-closed' | 'quick-search-results'
}
export interface QuickSearchResultData {

View File

@@ -0,0 +1,18 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const UserCurrentRecentCloseResetDocument = gql`
mutation userCurrentRecentCloseReset {
userCurrentRecentCloseReset {
success
}
}
`;
export function useUserCurrentRecentCloseResetMutation(options: VueApolloComposable.UseMutationOptions<Types.UserCurrentRecentCloseResetMutation, Types.UserCurrentRecentCloseResetMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<Types.UserCurrentRecentCloseResetMutation, Types.UserCurrentRecentCloseResetMutationVariables>> = {}) {
return VueApolloComposable.useMutation<Types.UserCurrentRecentCloseResetMutation, Types.UserCurrentRecentCloseResetMutationVariables>(UserCurrentRecentCloseResetDocument, options);
}
export type UserCurrentRecentCloseResetMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<Types.UserCurrentRecentCloseResetMutation, Types.UserCurrentRecentCloseResetMutationVariables>;

View File

@@ -0,0 +1,5 @@
mutation userCurrentRecentCloseReset {
userCurrentRecentCloseReset {
success
}
}

View File

@@ -0,0 +1,17 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './userCurrentRecentCloseReset.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function mockUserCurrentRecentCloseResetMutation(defaults: Mocks.MockDefaultsValue<Types.UserCurrentRecentCloseResetMutation, Types.UserCurrentRecentCloseResetMutationVariables>) {
return Mocks.mockGraphQLResult(Operations.UserCurrentRecentCloseResetDocument, defaults)
}
export function waitForUserCurrentRecentCloseResetMutationCalls() {
return Mocks.waitForGraphQLMockCalls<Types.UserCurrentRecentCloseResetMutation>(Operations.UserCurrentRecentCloseResetDocument)
}
export function mockUserCurrentRecentCloseResetMutationError(message: string, extensions: {type: ErrorTypes.GraphQLErrorTypes }) {
return Mocks.mockGraphQLResultWithError(Operations.UserCurrentRecentCloseResetDocument, message, extensions);
}

View File

@@ -1,18 +0,0 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const UserCurrentRecentViewResetDocument = gql`
mutation userCurrentRecentViewReset {
userCurrentRecentViewReset {
success
}
}
`;
export function useUserCurrentRecentViewResetMutation(options: VueApolloComposable.UseMutationOptions<Types.UserCurrentRecentViewResetMutation, Types.UserCurrentRecentViewResetMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<Types.UserCurrentRecentViewResetMutation, Types.UserCurrentRecentViewResetMutationVariables>> = {}) {
return VueApolloComposable.useMutation<Types.UserCurrentRecentViewResetMutation, Types.UserCurrentRecentViewResetMutationVariables>(UserCurrentRecentViewResetDocument, options);
}
export type UserCurrentRecentViewResetMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<Types.UserCurrentRecentViewResetMutation, Types.UserCurrentRecentViewResetMutationVariables>;

View File

@@ -1,5 +0,0 @@
mutation userCurrentRecentViewReset {
userCurrentRecentViewReset {
success
}
}

View File

@@ -1,17 +0,0 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './userCurrentRecentViewReset.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function mockUserCurrentRecentViewResetMutation(defaults: Mocks.MockDefaultsValue<Types.UserCurrentRecentViewResetMutation, Types.UserCurrentRecentViewResetMutationVariables>) {
return Mocks.mockGraphQLResult(Operations.UserCurrentRecentViewResetDocument, defaults)
}
export function waitForUserCurrentRecentViewResetMutationCalls() {
return Mocks.waitForGraphQLMockCalls<Types.UserCurrentRecentViewResetMutation>(Operations.UserCurrentRecentViewResetDocument)
}
export function mockUserCurrentRecentViewResetMutationError(message: string, extensions: {type: ErrorTypes.GraphQLErrorTypes }) {
return Mocks.mockGraphQLResultWithError(Operations.UserCurrentRecentViewResetDocument, message, extensions);
}

View File

@@ -0,0 +1,49 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const UserCurrentRecentCloseListDocument = gql`
query userCurrentRecentCloseList($limit: Int) {
userCurrentRecentCloseList(limit: $limit) {
... on Ticket {
id
internalId
title
number
state {
id
name
}
priority {
id
name
defaultCreate
uiColor
}
stateColorCode
}
... on User {
id
internalId
fullname
active
}
... on Organization {
id
internalId
name
active
}
}
}
`;
export function useUserCurrentRecentCloseListQuery(variables: Types.UserCurrentRecentCloseListQueryVariables | VueCompositionApi.Ref<Types.UserCurrentRecentCloseListQueryVariables> | ReactiveFunction<Types.UserCurrentRecentCloseListQueryVariables> = {}, options: VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentCloseListQuery, Types.UserCurrentRecentCloseListQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentCloseListQuery, Types.UserCurrentRecentCloseListQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentCloseListQuery, Types.UserCurrentRecentCloseListQueryVariables>> = {}) {
return VueApolloComposable.useQuery<Types.UserCurrentRecentCloseListQuery, Types.UserCurrentRecentCloseListQueryVariables>(UserCurrentRecentCloseListDocument, variables, options);
}
export function useUserCurrentRecentCloseListLazyQuery(variables: Types.UserCurrentRecentCloseListQueryVariables | VueCompositionApi.Ref<Types.UserCurrentRecentCloseListQueryVariables> | ReactiveFunction<Types.UserCurrentRecentCloseListQueryVariables> = {}, options: VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentCloseListQuery, Types.UserCurrentRecentCloseListQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentCloseListQuery, Types.UserCurrentRecentCloseListQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentCloseListQuery, Types.UserCurrentRecentCloseListQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<Types.UserCurrentRecentCloseListQuery, Types.UserCurrentRecentCloseListQueryVariables>(UserCurrentRecentCloseListDocument, variables, options);
}
export type UserCurrentRecentCloseListQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<Types.UserCurrentRecentCloseListQuery, Types.UserCurrentRecentCloseListQueryVariables>;

View File

@@ -1,5 +1,5 @@
query userCurrentRecentViewList($limit: Int) {
userCurrentRecentViewList(limit: $limit) {
query userCurrentRecentCloseList($limit: Int) {
userCurrentRecentCloseList(limit: $limit) {
... on Ticket {
id
internalId

View File

@@ -0,0 +1,17 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './userCurrentRecentCloseList.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function mockUserCurrentRecentCloseListQuery(defaults: Mocks.MockDefaultsValue<Types.UserCurrentRecentCloseListQuery, Types.UserCurrentRecentCloseListQueryVariables>) {
return Mocks.mockGraphQLResult(Operations.UserCurrentRecentCloseListDocument, defaults)
}
export function waitForUserCurrentRecentCloseListQueryCalls() {
return Mocks.waitForGraphQLMockCalls<Types.UserCurrentRecentCloseListQuery>(Operations.UserCurrentRecentCloseListDocument)
}
export function mockUserCurrentRecentCloseListQueryError(message: string, extensions: {type: ErrorTypes.GraphQLErrorTypes }) {
return Mocks.mockGraphQLResultWithError(Operations.UserCurrentRecentCloseListDocument, message, extensions);
}

View File

@@ -1,49 +0,0 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const UserCurrentRecentViewListDocument = gql`
query userCurrentRecentViewList($limit: Int) {
userCurrentRecentViewList(limit: $limit) {
... on Ticket {
id
internalId
title
number
state {
id
name
}
priority {
id
name
defaultCreate
uiColor
}
stateColorCode
}
... on User {
id
internalId
fullname
active
}
... on Organization {
id
internalId
name
active
}
}
}
`;
export function useUserCurrentRecentViewListQuery(variables: Types.UserCurrentRecentViewListQueryVariables | VueCompositionApi.Ref<Types.UserCurrentRecentViewListQueryVariables> | ReactiveFunction<Types.UserCurrentRecentViewListQueryVariables> = {}, options: VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentViewListQuery, Types.UserCurrentRecentViewListQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentViewListQuery, Types.UserCurrentRecentViewListQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentViewListQuery, Types.UserCurrentRecentViewListQueryVariables>> = {}) {
return VueApolloComposable.useQuery<Types.UserCurrentRecentViewListQuery, Types.UserCurrentRecentViewListQueryVariables>(UserCurrentRecentViewListDocument, variables, options);
}
export function useUserCurrentRecentViewListLazyQuery(variables: Types.UserCurrentRecentViewListQueryVariables | VueCompositionApi.Ref<Types.UserCurrentRecentViewListQueryVariables> | ReactiveFunction<Types.UserCurrentRecentViewListQueryVariables> = {}, options: VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentViewListQuery, Types.UserCurrentRecentViewListQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentViewListQuery, Types.UserCurrentRecentViewListQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.UserCurrentRecentViewListQuery, Types.UserCurrentRecentViewListQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<Types.UserCurrentRecentViewListQuery, Types.UserCurrentRecentViewListQueryVariables>(UserCurrentRecentViewListDocument, variables, options);
}
export type UserCurrentRecentViewListQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<Types.UserCurrentRecentViewListQuery, Types.UserCurrentRecentViewListQueryVariables>;

View File

@@ -1,17 +0,0 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './userCurrentRecentViewList.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function mockUserCurrentRecentViewListQuery(defaults: Mocks.MockDefaultsValue<Types.UserCurrentRecentViewListQuery, Types.UserCurrentRecentViewListQueryVariables>) {
return Mocks.mockGraphQLResult(Operations.UserCurrentRecentViewListDocument, defaults)
}
export function waitForUserCurrentRecentViewListQueryCalls() {
return Mocks.waitForGraphQLMockCalls<Types.UserCurrentRecentViewListQuery>(Operations.UserCurrentRecentViewListDocument)
}
export function mockUserCurrentRecentViewListQueryError(message: string, extensions: {type: ErrorTypes.GraphQLErrorTypes }) {
return Mocks.mockGraphQLResultWithError(Operations.UserCurrentRecentViewListDocument, message, extensions);
}

View File

@@ -0,0 +1,18 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const UserCurrentRecentCloseUpdatesDocument = gql`
subscription userCurrentRecentCloseUpdates {
userCurrentRecentCloseUpdates {
recentCloseUpdated
}
}
`;
export function useUserCurrentRecentCloseUpdatesSubscription(options: VueApolloComposable.UseSubscriptionOptions<Types.UserCurrentRecentCloseUpdatesSubscription, Types.UserCurrentRecentCloseUpdatesSubscriptionVariables> | VueCompositionApi.Ref<VueApolloComposable.UseSubscriptionOptions<Types.UserCurrentRecentCloseUpdatesSubscription, Types.UserCurrentRecentCloseUpdatesSubscriptionVariables>> | ReactiveFunction<VueApolloComposable.UseSubscriptionOptions<Types.UserCurrentRecentCloseUpdatesSubscription, Types.UserCurrentRecentCloseUpdatesSubscriptionVariables>> = {}) {
return VueApolloComposable.useSubscription<Types.UserCurrentRecentCloseUpdatesSubscription, Types.UserCurrentRecentCloseUpdatesSubscriptionVariables>(UserCurrentRecentCloseUpdatesDocument, {}, options);
}
export type UserCurrentRecentCloseUpdatesSubscriptionCompositionFunctionResult = VueApolloComposable.UseSubscriptionReturn<Types.UserCurrentRecentCloseUpdatesSubscription, Types.UserCurrentRecentCloseUpdatesSubscriptionVariables>;

View File

@@ -0,0 +1,5 @@
subscription userCurrentRecentCloseUpdates {
userCurrentRecentCloseUpdates {
recentCloseUpdated
}
}

View File

@@ -1,9 +1,9 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './userCurrentRecentViewUpdates.api.ts'
import * as Operations from './userCurrentRecentCloseUpdates.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function getUserCurrentRecentViewUpdatesSubscriptionHandler() {
return Mocks.getGraphQLSubscriptionHandler<Types.UserCurrentRecentViewUpdatesSubscription>(Operations.UserCurrentRecentViewUpdatesDocument)
export function getUserCurrentRecentCloseUpdatesSubscriptionHandler() {
return Mocks.getGraphQLSubscriptionHandler<Types.UserCurrentRecentCloseUpdatesSubscription>(Operations.UserCurrentRecentCloseUpdatesDocument)
}

View File

@@ -1,18 +0,0 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const UserCurrentRecentViewUpdatesDocument = gql`
subscription userCurrentRecentViewUpdates {
userCurrentRecentViewUpdates {
recentViewsUpdated
}
}
`;
export function useUserCurrentRecentViewUpdatesSubscription(options: VueApolloComposable.UseSubscriptionOptions<Types.UserCurrentRecentViewUpdatesSubscription, Types.UserCurrentRecentViewUpdatesSubscriptionVariables> | VueCompositionApi.Ref<VueApolloComposable.UseSubscriptionOptions<Types.UserCurrentRecentViewUpdatesSubscription, Types.UserCurrentRecentViewUpdatesSubscriptionVariables>> | ReactiveFunction<VueApolloComposable.UseSubscriptionOptions<Types.UserCurrentRecentViewUpdatesSubscription, Types.UserCurrentRecentViewUpdatesSubscriptionVariables>> = {}) {
return VueApolloComposable.useSubscription<Types.UserCurrentRecentViewUpdatesSubscription, Types.UserCurrentRecentViewUpdatesSubscriptionVariables>(UserCurrentRecentViewUpdatesDocument, {}, options);
}
export type UserCurrentRecentViewUpdatesSubscriptionCompositionFunctionResult = VueApolloComposable.UseSubscriptionReturn<Types.UserCurrentRecentViewUpdatesSubscription, Types.UserCurrentRecentViewUpdatesSubscriptionVariables>;

View File

@@ -1,5 +0,0 @@
subscription userCurrentRecentViewUpdates {
userCurrentRecentViewUpdates {
recentViewsUpdated
}
}

View File

@@ -19,7 +19,7 @@ import { ConfigUpdatesDocument } from '#shared/graphql/subscriptions/configUpdat
import { useApplicationStore } from '#shared/stores/application.ts'
import { useAuthenticationStore } from '#shared/stores/authentication.ts'
import { UserCurrentRecentViewUpdatesDocument } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentRecentViewUpdates.api.ts'
import { UserCurrentRecentCloseUpdatesDocument } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentRecentCloseUpdates.api.ts'
import { UserCurrentTaskbarItemListUpdatesDocument } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentTaskbarItemListUpdates.api.ts'
import { UserCurrentTaskbarItemUpdatesDocument } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentTaskbarItemUpdates.api.ts'
@@ -40,7 +40,7 @@ beforeEach(() => {
mockGraphQLSubscription(UserCurrentTaskbarItemUpdatesDocument)
mockGraphQLSubscription(UserCurrentTaskbarItemListUpdatesDocument)
mockGraphQLSubscription(OnlineNotificationsCountDocument)
mockGraphQLSubscription(UserCurrentRecentViewUpdatesDocument)
mockGraphQLSubscription(UserCurrentRecentCloseUpdatesDocument)
})
describe('testing login maintenance mode', () => {

View File

@@ -1789,8 +1789,8 @@ export type Mutations = {
userCurrentOverviewUpdateOrder?: Maybe<UserCurrentOverviewUpdateOrderPayload>;
/** Check your password */
userCurrentPasswordCheck?: Maybe<UserCurrentPasswordCheckPayload>;
/** Reset recent view records of the current user */
userCurrentRecentViewReset?: Maybe<UserCurrentRecentViewResetPayload>;
/** Reset recently closed items of the current user */
userCurrentRecentCloseReset?: Maybe<UserCurrentRecentCloseResetPayload>;
/** Remove a linked account of the current user's profile */
userCurrentRemoveLinkedAccount?: Maybe<UserCurrentRemoveLinkedAccountPayload>;
/** Create a taskbar item for the currently logged-in user. */
@@ -3013,8 +3013,8 @@ export type Queries = {
userCurrentCalendarSubscriptionList: UserPersonalSettingsCalendarSubscriptionsConfig;
/** Fetch available device list of the currently logged-in user */
userCurrentDeviceList?: Maybe<Array<UserDevice>>;
/** Fetch tickets recently viewed by the current user */
userCurrentRecentViewList: Array<RecentView>;
/** Fetch recently closed objects for the current user */
userCurrentRecentCloseList: Array<RecentClose>;
/** Fetch available taskbar item list of the currently logged-in user */
userCurrentTaskbarItemList?: Maybe<Array<UserTaskbarItem>>;
/** Ticket overviews according to the personal sorting of the current user */
@@ -3364,7 +3364,7 @@ export type QueriesUserArgs = {
/** All available queries */
export type QueriesUserCurrentRecentViewListArgs = {
export type QueriesUserCurrentRecentCloseListArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
};
@@ -3394,8 +3394,8 @@ export type QueriesUserCurrentTwoFactorInitiateMethodConfigurationArgs = {
token: Scalars['String']['input'];
};
/** Objects recently viewed */
export type RecentView = Organization | Ticket | User;
/** Recently closed object */
export type RecentClose = Organization | Ticket | User;
/** Roles */
export type Role = {
@@ -3539,8 +3539,8 @@ export type Subscriptions = {
userCurrentDevicesUpdates: UserCurrentDevicesUpdatesPayload;
/** Updates to account overview sorting records */
userCurrentOverviewOrderingUpdates: UserCurrentOverviewOrderingUpdatesPayload;
/** Updates to the recently viewed items of the current user */
userCurrentRecentViewUpdates: UserCurrentRecentViewUpdatesPayload;
/** Updates for the recently closed items of the current user */
userCurrentRecentCloseUpdates: UserCurrentRecentCloseUpdatesPayload;
/** Subscription for taskbar item list priority changes */
userCurrentTaskbarItemListUpdates: UserCurrentTaskbarItemListUpdatesPayload;
/** Changes to the state of a taskbar item of the currently logged-in user */
@@ -5294,20 +5294,20 @@ export type UserCurrentPasswordCheckPayload = {
token?: Maybe<Scalars['String']['output']>;
};
/** Autogenerated return type of UserCurrentRecentViewReset. */
export type UserCurrentRecentViewResetPayload = {
__typename?: 'UserCurrentRecentViewResetPayload';
/** Autogenerated return type of UserCurrentRecentCloseReset. */
export type UserCurrentRecentCloseResetPayload = {
__typename?: 'UserCurrentRecentCloseResetPayload';
/** Errors encountered during execution of the mutation. */
errors?: Maybe<Array<UserError>>;
/** Was the reset successful? */
success: Scalars['Boolean']['output'];
};
/** Autogenerated return type of UserCurrentRecentViewUpdates. */
export type UserCurrentRecentViewUpdatesPayload = {
__typename?: 'UserCurrentRecentViewUpdatesPayload';
/** The recent view list of the user has changed. */
recentViewsUpdated?: Maybe<Scalars['Boolean']['output']>;
/** Autogenerated return type of UserCurrentRecentCloseUpdates. */
export type UserCurrentRecentCloseUpdatesPayload = {
__typename?: 'UserCurrentRecentCloseUpdatesPayload';
/** The recently closed list of the user has changed. */
recentCloseUpdated?: Maybe<Scalars['Boolean']['output']>;
};
/** Autogenerated return type of UserCurrentRemoveLinkedAccount. */
@@ -6099,10 +6099,10 @@ export type UserCurrentPasswordCheckMutationVariables = Exact<{
export type UserCurrentPasswordCheckMutation = { __typename?: 'Mutations', userCurrentPasswordCheck?: { __typename?: 'UserCurrentPasswordCheckPayload', success?: boolean | null, token?: string | null, errors?: Array<{ __typename?: 'UserError', message: string, messagePlaceholder?: Array<string> | null, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type UserCurrentRecentViewResetMutationVariables = Exact<{ [key: string]: never; }>;
export type UserCurrentRecentCloseResetMutationVariables = Exact<{ [key: string]: never; }>;
export type UserCurrentRecentViewResetMutation = { __typename?: 'Mutations', userCurrentRecentViewReset?: { __typename?: 'UserCurrentRecentViewResetPayload', success: boolean } | null };
export type UserCurrentRecentCloseResetMutation = { __typename?: 'Mutations', userCurrentRecentCloseReset?: { __typename?: 'UserCurrentRecentCloseResetPayload', success: boolean } | null };
export type UserCurrentTaskbarItemAddMutationVariables = Exact<{
input: UserTaskbarItemInput;
@@ -6165,12 +6165,12 @@ export type UserCurrentTicketScreenBehaviorMutationVariables = Exact<{
export type UserCurrentTicketScreenBehaviorMutation = { __typename?: 'Mutations', userCurrentTicketScreenBehavior?: { __typename?: 'UserCurrentTicketScreenBehaviorPayload', success: boolean, errors?: Array<{ __typename?: 'UserError', message: string, messagePlaceholder?: Array<string> | null, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type UserCurrentRecentViewListQueryVariables = Exact<{
export type UserCurrentRecentCloseListQueryVariables = Exact<{
limit?: InputMaybe<Scalars['Int']['input']>;
}>;
export type UserCurrentRecentViewListQuery = { __typename?: 'Queries', userCurrentRecentViewList: Array<
export type UserCurrentRecentCloseListQuery = { __typename?: 'Queries', userCurrentRecentCloseList: Array<
| { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null }
| { __typename?: 'Ticket', id: string, internalId: number, title: string, number: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null } }
| { __typename?: 'User', id: string, internalId: number, fullname?: string | null, active?: boolean | null }
@@ -6189,10 +6189,10 @@ export type UserCurrentTaskbarItemListQuery = { __typename?: 'Queries', userCurr
| { __typename?: 'UserTaskbarItemEntityTicketCreate', uid: string, title: string, createArticleTypeKey?: string | null }
| null }> | null };
export type UserCurrentRecentViewUpdatesSubscriptionVariables = Exact<{ [key: string]: never; }>;
export type UserCurrentRecentCloseUpdatesSubscriptionVariables = Exact<{ [key: string]: never; }>;
export type UserCurrentRecentViewUpdatesSubscription = { __typename?: 'Subscriptions', userCurrentRecentViewUpdates: { __typename?: 'UserCurrentRecentViewUpdatesPayload', recentViewsUpdated?: boolean | null } };
export type UserCurrentRecentCloseUpdatesSubscription = { __typename?: 'Subscriptions', userCurrentRecentCloseUpdates: { __typename?: 'UserCurrentRecentCloseUpdatesPayload', recentCloseUpdated?: boolean | null } };
export type UserCurrentTaskbarItemListUpdatesSubscriptionVariables = Exact<{
app: EnumTaskbarApp;

View File

@@ -0,0 +1,9 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import type { UserCurrentRecentCloseListQuery } from '#shared/graphql/types.ts'
export default (): UserCurrentRecentCloseListQuery => {
return {
userCurrentRecentCloseList: [],
}
}

View File

@@ -1,9 +0,0 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import type { UserCurrentRecentViewListQuery } from '#shared/graphql/types.ts'
export default (): UserCurrentRecentViewListQuery => {
return {
userCurrentRecentViewList: [],
}
}

View File

@@ -0,0 +1,20 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Mutations
class User::Current::RecentClose::Reset < BaseMutation
description 'Reset recently closed items of the current user'
field :success, Boolean, null: false, description: 'Was the reset successful?'
def resolve
context
.current_user
.recent_closes
.delete_all
Gql::Subscriptions::User::Current::RecentClose::Updates.trigger({}, scope: context.current_user.id)
{ success: true }
end
end
end

View File

@@ -1,16 +0,0 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Mutations
class User::Current::RecentView::Reset < BaseMutation
description 'Reset recent view records of the current user'
field :success, Boolean, null: false, description: 'Was the reset successful?'
def resolve
RecentView.where(created_by_id: context.current_user.id).delete_all
Gql::Subscriptions::User::Current::RecentView::Updates.trigger({}, scope: context.current_user.id)
{ success: true }
end
end
end

View File

@@ -0,0 +1,18 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Queries
class User::Current::RecentClose::List < BaseQuery
description 'Fetch recently closed objects for the current user'
argument :limit, Integer, required: false, description: 'Limit for the amount of returned entries'
type [Gql::Types::RecentCloseType], null: false
def resolve(limit: 10)
Service::User::ListRecentCloses
.new(context.current_user, limit:)
.execute
end
end
end

View File

@@ -1,18 +0,0 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Queries
class User::Current::RecentView::List < BaseQuery
description 'Fetch tickets recently viewed by the current user'
argument :limit, Integer, required: false, description: 'Limit for the amount of entries'
type [Gql::Types::RecentViewType], null: false
def resolve(limit: 10)
::RecentView.list(context.current_user, limit).map do |recent_view|
ObjectLookup.by_id(recent_view.recent_view_object_id).safe_constantize.lookup(id: recent_view.o_id)
end
end
end
end

View File

@@ -0,0 +1,16 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Subscriptions
class User::Current::RecentClose::Updates < BaseSubscription
description 'Updates for the recently closed items of the current user'
subscription_scope :current_user_id
field :recent_close_updated, Boolean, null: true, description: 'The recently closed list of the user has changed.'
def update
{ recent_close_updated: true }
end
end
end

View File

@@ -1,16 +0,0 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Subscriptions
class User::Current::RecentView::Updates < BaseSubscription
description 'Updates to the recently viewed items of the current user'
subscription_scope :current_user_id
field :recent_views_updated, Boolean, null: true, description: 'The recent view list of the user has changed.'
def update
{ recent_views_updated: true }
end
end
end

View File

@@ -1,8 +1,8 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Types
class RecentViewType < BaseUnion
description 'Objects recently viewed'
class RecentCloseType < BaseUnion
description 'Recently closed object'
possible_types Gql::Types::TicketType, Gql::Types::UserType, Gql::Types::OrganizationType
end
end

View File

@@ -12610,12 +12610,12 @@
"deprecationReason": null
},
{
"name": "userCurrentRecentViewReset",
"description": "Reset recent view records of the current user",
"name": "userCurrentRecentCloseReset",
"description": "Reset recently closed items of the current user",
"args": [],
"type": {
"kind": "OBJECT",
"name": "UserCurrentRecentViewResetPayload",
"name": "UserCurrentRecentCloseResetPayload",
"ofType": null
},
"isDeprecated": false,
@@ -18281,12 +18281,12 @@
"deprecationReason": null
},
{
"name": "userCurrentRecentViewList",
"description": "Fetch tickets recently viewed by the current user",
"name": "userCurrentRecentCloseList",
"description": "Fetch recently closed objects for the current user",
"args": [
{
"name": "limit",
"description": "Limit for the amount of entries",
"description": "Limit for the amount of returned entries",
"type": {
"kind": "SCALAR",
"name": "Int",
@@ -18306,7 +18306,7 @@
"name": null,
"ofType": {
"kind": "UNION",
"name": "RecentView",
"name": "RecentClose",
"ofType": null
}
}
@@ -18495,8 +18495,8 @@
},
{
"kind": "UNION",
"name": "RecentView",
"description": "Objects recently viewed",
"name": "RecentClose",
"description": "Recently closed object",
"fields": null,
"inputFields": null,
"interfaces": null,
@@ -19708,15 +19708,15 @@
"deprecationReason": null
},
{
"name": "userCurrentRecentViewUpdates",
"description": "Updates to the recently viewed items of the current user",
"name": "userCurrentRecentCloseUpdates",
"description": "Updates for the recently closed items of the current user",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "UserCurrentRecentViewUpdatesPayload",
"name": "UserCurrentRecentCloseUpdatesPayload",
"ofType": null
}
},
@@ -29458,8 +29458,8 @@
},
{
"kind": "OBJECT",
"name": "UserCurrentRecentViewResetPayload",
"description": "Autogenerated return type of UserCurrentRecentViewReset.",
"name": "UserCurrentRecentCloseResetPayload",
"description": "Autogenerated return type of UserCurrentRecentCloseReset.",
"fields": [
{
"name": "errors",
@@ -29505,12 +29505,12 @@
},
{
"kind": "OBJECT",
"name": "UserCurrentRecentViewUpdatesPayload",
"description": "Autogenerated return type of UserCurrentRecentViewUpdates.",
"name": "UserCurrentRecentCloseUpdatesPayload",
"description": "Autogenerated return type of UserCurrentRecentCloseUpdates.",
"fields": [
{
"name": "recentViewsUpdated",
"description": "The recent view list of the user has changed.",
"name": "recentCloseUpdated",
"description": "The recently closed list of the user has changed.",
"args": [],
"type": {
"kind": "SCALAR",

View File

@@ -0,0 +1,13 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module HasRecentCloses
extend ActiveSupport::Concern
included do
after_destroy_commit :recent_close_destroy
end
def recent_close_destroy
RecentClose.destroy_logs(self)
end
end

View File

@@ -12,6 +12,7 @@ class Organization < ApplicationModel
include ChecksHtmlSanitized
include HasObjectManagerAttributes
include HasTaskbars
include HasRecentCloses
include CanSelector
include CanPerformChanges

View File

@@ -0,0 +1,38 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class RecentClose < ApplicationModel
belongs_to :user, optional: false
belongs_to :recently_closed_object, polymorphic: true, optional: false
validates :user, uniqueness: { scope: %i[recently_closed_object_type recently_closed_object_id] }
after_commit :trigger_subscriptions
def self.upsert_closing_time!(user, object)
transaction do
recent_close = find_or_initialize_by(user:, recently_closed_object: object)
recent_close.updated_at = Time.current
recent_close.save!
recent_close
end
end
def self.cleanup(diff = 3.months)
where(updated_at: ...diff.ago)
.delete_all
true
end
def self.destroy_logs(object)
where(recently_closed_object: object).each(&:destroy)
end
private
def trigger_subscriptions
Gql::Subscriptions::User::Current::RecentClose::Updates
.trigger({}, scope: user_id)
end
end

View File

@@ -76,8 +76,6 @@ class RecentView < ApplicationModel
data: {}
}
)
Gql::Subscriptions::User::Current::RecentView::Updates.trigger({}, scope: created_by_id)
end
def self.access(object, o_id, user)

View File

@@ -28,6 +28,7 @@ class Taskbar < ApplicationModel
after_update :notify_clients
after_destroy :update_preferences_infos, :notify_clients
after_commit :update_related_taskbars
after_destroy_commit :log_recent_close
association_attributes_ignored :user
@@ -285,4 +286,12 @@ class Taskbar < ApplicationModel
data,
)
end
def log_recent_close
object = to_object
return if !object
RecentClose.upsert_closing_time!(user, to_object)
end
end

View File

@@ -14,6 +14,7 @@ class Ticket < ApplicationModel
include HasLinks
include HasObjectManagerAttributes
include HasTaskbars
include HasRecentCloses
include Ticket::CallsStatsTicketReopenLog
include Ticket::EnqueuesUserTicketCounterJob
include Ticket::ResetsPendingTimeSeconds

View File

@@ -14,6 +14,7 @@ class User < ApplicationModel
include HasObjectManagerAttributes
include HasTaskbars
include HasTwoFactor
include HasRecentCloses
include CanSelector
include CanPerformChanges
include User::Assets
@@ -43,6 +44,7 @@ class User < ApplicationModel
has_many :owner_tickets, class_name: 'Ticket', foreign_key: :owner_id, inverse_of: :owner
has_many :overview_sortings, dependent: :destroy
has_many :created_recent_views, class_name: 'RecentView', foreign_key: :created_by_id, dependent: :destroy, inverse_of: :created_by
has_many :recent_closes, dependent: :delete_all
has_many :data_privacy_tasks, as: :deletable
belongs_to :organization, inverse_of: :members, optional: true

View File

@@ -0,0 +1,88 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class Service::User::ListRecentCloses < Service::Base
attr_reader :user, :limit
def initialize(user, limit: 10)
super()
@user = user
@limit = limit
end
def execute
objects_in_order
end
private
def ordered_recent_closes
@ordered_recent_closes ||= user.recent_closes.reorder(updated_at: :desc).limit(limit)
end
def grouped_recent_closes
@grouped_recent_closes ||= ordered_recent_closes.group_by(&:recently_closed_object_type)
end
def objects_in_order
@objects_in_order ||= ordered_recent_closes
.filter_map do |item|
case item.recently_closed_object_type
when 'Ticket'
tickets.find { it.id == item.recently_closed_object_id }
when 'Organization'
organizations.find { it.id == item.recently_closed_object_id }
when 'User'
users.find { it.id == item.recently_closed_object_id }
end
end
end
def tickets
@tickets ||= fetch_tickets
end
def organizations
@organizations ||= fetch_organizations
end
def users
@users ||= fetch_users
end
def fetch_tickets
return [] if grouped_recent_closes['Ticket'].blank?
ticket_ids = grouped_recent_closes['Ticket'].map(&:recently_closed_object_id)
TicketPolicy::ReadScope
.new(user)
.resolve
.where(id: ticket_ids)
.to_a
end
def fetch_organizations
return [] if grouped_recent_closes['Organization'].blank?
organization_ids = grouped_recent_closes['Organization'].map(&:recently_closed_object_id)
OrganizationPolicy::Scope
.new(user, Organization.all)
.resolve
.where(id: organization_ids)
.to_a
end
def fetch_users
return [] if grouped_recent_closes['User'].blank?
user_ids = grouped_recent_closes['User'].map(&:recently_closed_object_id)
UserPolicy::Scope
.new(user, User.all)
.resolve
.where(id: user_ids)
.to_a
end
end

View File

@@ -6,17 +6,17 @@
"fingerprint": "08dca0d9a45edb498cd57df9241c2a39bd0a7c13f1042bfdb8725cfdfe71f327",
"check_name": "UnsafeReflection",
"message": "Unsafe reflection method `safe_constantize` called on model attribute",
"file": "app/graphql/gql/queries/user/current/recent_view/list.rb",
"file": "app/graphql/gql/queries/user/current/recent_close/list.rb",
"line": 14,
"link": "https://brakemanscanner.org/docs/warning_types/remote_code_execution/",
"code": "ObjectLookup.by_id(recent_view.recent_view_object_id).safe_constantize",
"code": "ObjectLookup.by_id(recent_close.recent_close_object_id).safe_constantize",
"render_path": null,
"location": {
"type": "method",
"class": "Gql::Queries::User::Current::RecentView::List",
"class": "Gql::Queries::User::Current::RecentClose::List",
"method": "resolve"
},
"user_input": "ObjectLookup.by_id(recent_view.recent_view_object_id)",
"user_input": "ObjectLookup.by_id(recent_close.recent_close_object_id)",
"confidence": "Medium",
"cwe_id": [
470

View File

@@ -1023,5 +1023,18 @@ class CreateBase < ActiveRecord::Migration[4.2]
t.index %i[ai_analytics_run_id user_id], unique: true
end
create_table :recent_closes do |t|
t.references :recently_closed_object, polymorphic: true, null: false, type: :integer
t.references :user, null: false, foreign_key: true, type: :integer
t.timestamps limit: 3
t.index %i[recently_closed_object_type recently_closed_object_id user_id],
name: 'index_recent_closed_user_object',
unique: true
t.index :updated_at, order: { updated_at: :desc }
end
end
end

View File

@@ -0,0 +1,32 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class CreateRecentCloses < ActiveRecord::Migration[7.2]
def change
# return if it's a new setup
return if !Setting.exists?(name: 'system_init_done')
create_table :recent_closes do |t|
t.references :recently_closed_object, polymorphic: true, null: false, type: :integer
t.references :user, null: false, foreign_key: true, type: :integer
t.timestamps limit: 3
t.index %i[recently_closed_object_type recently_closed_object_id user_id],
name: 'index_recent_closed_user_object',
unique: true
t.index :updated_at, order: { updated_at: :desc }
end
Scheduler.create_if_not_exists(
name: 'Delete old recently closed entries.',
method: 'RecentClose.cleanup',
period: 1.day,
prio: 2,
active: true,
updated_by_id: 1,
created_by_id: 1,
last_run: Time.current,
)
end
end

View File

@@ -66,6 +66,15 @@ Scheduler.create_if_not_exists(
updated_by_id: 1,
created_by_id: 1,
)
Scheduler.create_if_not_exists(
name: __('Delete old recently closed entries.'),
method: 'RecentClose.cleanup',
period: 1.day,
prio: 2,
active: true,
updated_by_id: 1,
created_by_id: 1,
)
Scheduler.create_or_update(
name: __('Delete old online notification entries.'),
method: 'OnlineNotification.cleanup',

View File

@@ -1409,7 +1409,7 @@ msgid "Agent idle timeout"
msgstr ""
#: app/models/role.rb:151
#: app/models/user.rb:755
#: app/models/user.rb:756
msgid "Agent limit exceeded, please check your account settings."
msgstr ""
@@ -1862,7 +1862,7 @@ msgstr ""
msgid "Are you sure? The user will have to to reconfigure all two-factor authentication methods."
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:83
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:84
msgid "Are you sure? This recent search will be lost."
msgstr ""
@@ -1884,12 +1884,12 @@ msgstr ""
msgid "Are you sure? Your notifications settings will be reset to default."
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:100
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:101
msgid "Are you sure? Your recent searches will be lost."
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:119
msgid "Are you sure? Your recently viewed items will get lost."
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:120
msgid "Are you sure? Your recently closed items will get lost."
msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_action.coffee:54
@@ -1992,7 +1992,7 @@ msgstr ""
msgid "At least one filter must be provided."
msgstr ""
#: app/models/user.rb:657
#: app/models/user.rb:658
msgid "At least one identifier (firstname, lastname, phone, mobile or email) for user is required."
msgstr ""
@@ -2005,7 +2005,7 @@ msgid "At least one object must be selected."
msgstr ""
#: app/models/role.rb:128
#: app/models/user.rb:734
#: app/models/user.rb:735
msgid "At least one user needs to have admin permissions."
msgstr ""
@@ -3115,31 +3115,31 @@ msgstr ""
msgid "City"
msgstr ""
#: db/seeds/schedulers.rb:160
#: db/seeds/schedulers.rb:169
msgid "Clean up 'Cti::Log'."
msgstr ""
#: db/seeds/schedulers.rb:169
#: db/seeds/schedulers.rb:178
msgid "Clean up 'DataPrivacyTask'."
msgstr ""
#: db/seeds/schedulers.rb:151
#: db/seeds/schedulers.rb:160
msgid "Clean up 'HttpLog'."
msgstr ""
#: db/seeds/schedulers.rb:106
#: db/seeds/schedulers.rb:115
msgid "Clean up ActiveJob locks."
msgstr ""
#: db/seeds/schedulers.rb:215
#: db/seeds/schedulers.rb:224
msgid "Clean up cache."
msgstr ""
#: db/seeds/schedulers.rb:97
#: db/seeds/schedulers.rb:106
msgid "Clean up closed sessions."
msgstr ""
#: db/seeds/schedulers.rb:115
#: db/seeds/schedulers.rb:124
msgid "Clean up dead sessions."
msgstr ""
@@ -3147,7 +3147,7 @@ msgstr ""
msgid "Clean up expired sessions."
msgstr ""
#: db/seeds/schedulers.rb:250
#: db/seeds/schedulers.rb:259
msgid "Clean up mobile taskbars."
msgstr ""
@@ -3172,12 +3172,12 @@ msgstr ""
msgid "Clear filter"
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:190
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:194
msgid "Clear recent searches"
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:218
msgid "Clear recently viewed"
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:222
msgid "Clear recently closed"
msgstr ""
#: app/frontend/apps/desktop/pages/search/components/SearchEmptyMessage.vue:34
@@ -3262,7 +3262,7 @@ msgstr ""
msgid "Close In Min"
msgstr ""
#: db/seeds/schedulers.rb:88
#: db/seeds/schedulers.rb:97
msgid "Close chat sessions where participants are offline."
msgstr ""
@@ -5210,7 +5210,7 @@ msgstr ""
msgid "Delete immediately"
msgstr ""
#: db/seeds/schedulers.rb:178
#: db/seeds/schedulers.rb:187
msgid "Delete obsolete classic IMAP backup."
msgstr ""
@@ -5222,19 +5222,23 @@ msgstr ""
msgid "Delete old activity stream entries."
msgstr ""
#: db/seeds/schedulers.rb:70
#: db/seeds/schedulers.rb:79
msgid "Delete old online notification entries."
msgstr ""
#: db/seeds/schedulers.rb:142
#: db/seeds/schedulers.rb:70
msgid "Delete old recently closed entries."
msgstr ""
#: db/seeds/schedulers.rb:151
msgid "Delete old stats store entries."
msgstr ""
#: db/seeds/schedulers.rb:79
#: db/seeds/schedulers.rb:88
msgid "Delete old token entries."
msgstr ""
#: db/seeds/schedulers.rb:206
#: db/seeds/schedulers.rb:215
msgid "Delete old upload cache entries."
msgstr ""
@@ -5270,7 +5274,7 @@ msgstr ""
msgid "Delete this link"
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:175
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:179
msgid "Delete this recent search"
msgstr ""
@@ -6034,7 +6038,7 @@ msgstr ""
msgid "Email address"
msgstr ""
#: app/models/user.rb:667
#: app/models/user.rb:668
msgid "Email address '%{email}' is already used for another user."
msgstr ""
@@ -6642,7 +6646,7 @@ msgstr ""
msgid "Execute configured macros"
msgstr ""
#: db/seeds/schedulers.rb:187
#: db/seeds/schedulers.rb:196
msgid "Execute import jobs."
msgstr ""
@@ -7283,7 +7287,7 @@ msgstr ""
msgid "Generate recovery codes"
msgstr ""
#: db/seeds/schedulers.rb:133
#: db/seeds/schedulers.rb:142
msgid "Generate user-based stats."
msgstr ""
@@ -7652,7 +7656,7 @@ msgstr ""
msgid "HTTP type"
msgstr ""
#: db/seeds/schedulers.rb:196
#: db/seeds/schedulers.rb:205
msgid "Handle data privacy tasks."
msgstr ""
@@ -8551,7 +8555,7 @@ msgstr ""
msgid "Invalid client_id received!"
msgstr ""
#: app/models/user.rb:606
#: app/models/user.rb:607
msgid "Invalid email '%{email}'"
msgstr ""
@@ -10123,7 +10127,7 @@ msgstr ""
msgid "More information can be found here."
msgstr ""
#: app/models/user.rb:686
#: app/models/user.rb:687
msgid "More than 250 secondary organizations are not allowed."
msgstr ""
@@ -11147,7 +11151,7 @@ msgstr ""
#: app/assets/javascripts/app/views/profile/notification.jst.eco:2
#: app/assets/javascripts/app/views/widget/mention.jst.eco:1
#: app/assets/javascripts/app/views/widget/online_notification.jst.eco:3
#: app/frontend/apps/desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification.vue:155
#: app/frontend/apps/desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification.vue:146
#: app/frontend/apps/desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification/NotificationPopover/NotificationHeader.vue:20
#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/notifications.ts:6
#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingNotifications.vue:34
@@ -12689,16 +12693,16 @@ msgstr ""
msgid "Recent Logs"
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:94
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:95
msgid "Recent search was removed successfully."
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:111
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:112
#: app/frontend/apps/mobile/pages/search/views/SearchOverview.vue:251
msgid "Recent searches"
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:111
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:112
msgid "Recent searches were cleared successfully."
msgstr ""
@@ -12708,13 +12712,16 @@ msgstr ""
msgid "Recently Viewed Tickets"
msgstr ""
#: app/assets/javascripts/app/controllers/_plugin/navigation.coffee:526
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:129
msgid "Recently viewed"
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:133
msgid "Recently closed"
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:129
msgid "Recently viewed items were cleared successfully."
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:133
msgid "Recently closed items were cleared successfully."
msgstr ""
#: app/assets/javascripts/app/controllers/_plugin/navigation.coffee:526
msgid "Recently viewed"
msgstr ""
#: app/assets/javascripts/app/controllers/_channel/sms.coffee:397
@@ -13787,11 +13794,11 @@ msgstr ""
msgid "Secondary organizations"
msgstr ""
#: app/models/user.rb:674
#: app/models/user.rb:675
msgid "Secondary organizations are only allowed when the primary organization is given."
msgstr ""
#: app/models/user.rb:680
#: app/models/user.rb:681
msgid "Secondary organizations cannot include the primary organization."
msgstr ""
@@ -14809,7 +14816,7 @@ msgstr ""
msgid "Start new conversation"
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:226
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearch.vue:231
msgid "Start typing e.g. the name of a ticket, an organization or a user."
msgstr ""
@@ -15145,7 +15152,7 @@ msgstr ""
msgid "Switch to old interface"
msgstr ""
#: db/seeds/schedulers.rb:124
#: db/seeds/schedulers.rb:133
msgid "Sync calendars with iCal feeds."
msgstr ""
@@ -18354,7 +18361,7 @@ msgstr ""
msgid "Update as closed"
msgstr ""
#: db/seeds/schedulers.rb:241
#: db/seeds/schedulers.rb:250
msgid "Update exchange oauth 2 token."
msgstr ""
@@ -20582,7 +20589,7 @@ msgstr ""
msgid "is the wrong length (should be 1 character)"
msgstr ""
#: app/models/user.rb:871
#: app/models/user.rb:872
msgid "is too long"
msgstr ""

View File

@@ -0,0 +1,8 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
FactoryBot.define do
factory :recent_close do
recently_closed_object { association(:ticket) }
user { association(:agent) }
end
end

View File

@@ -0,0 +1,49 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Gql::Mutations::User::Current::RecentClose::Reset, :aggregate_failures, type: :graphql do
let(:group) { create(:group) }
let(:ticket) { create(:ticket, group:) }
let(:user) { create(:agent, groups: [group]) }
let(:recent_close_ticket) { create(:recent_close, user:, recently_closed_object: ticket) }
let(:recent_close_user) { create(:recent_close, user:, recently_closed_object: user) }
let(:mutation) do
<<~GQL
mutation userCurrentRecentCloseReset {
userCurrentRecentCloseReset {
success
}
}
GQL
end
def execute_graphql_query
gql.execute(mutation)
end
context 'when user is authenticated', authenticated_as: :user do
before do
recent_close_ticket && recent_close_user
allow(Gql::Subscriptions::User::Current::RecentClose::Updates).to receive(:trigger)
end
it 'resets recent view entries' do
execute_graphql_query
expect(gql.result.data[:success]).to be(true)
expect(user.recent_closes).to be_empty
end
it 'triggers the recent view updates subscription only once' do
execute_graphql_query
expect(Gql::Subscriptions::User::Current::RecentClose::Updates).to have_received(:trigger).exactly(1).with({}, scope: user.id)
end
end
context 'when user is not authenticated' do
it 'returns an error' do
expect(execute_graphql_query.error_message).to eq('Authentication required')
end
end
end

View File

@@ -1,48 +0,0 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Gql::Mutations::User::Current::RecentView::Reset, :aggregate_failures, type: :graphql do
let(:mutation) do
<<~GQL
mutation userCurrentRecentViewReset {
userCurrentRecentViewReset {
success
}
}
GQL
end
let(:group) { create(:group) }
let(:ticket) { create(:ticket, group:) }
let(:user) { create(:agent, groups: [group]) }
def execute_graphql_query
gql.execute(mutation)
end
context 'when user is authenticated', authenticated_as: :user do
before do
RecentView.log('Ticket', ticket.id, user)
RecentView.log('User', user.id, user)
allow(Gql::Subscriptions::User::Current::RecentView::Updates).to receive(:trigger)
end
it 'resets recent view entries' do
execute_graphql_query
expect(gql.result.data[:success]).to be(true)
expect(RecentView.list(user)).to be_empty
end
it 'triggers the recent view updates subscription only once' do
execute_graphql_query
expect(Gql::Subscriptions::User::Current::RecentView::Updates).to have_received(:trigger).exactly(1).with({}, scope: user.id)
end
end
context 'when user is not authenticated' do
it 'returns an error' do
expect(execute_graphql_query.error_message).to eq('Authentication required')
end
end
end

View File

@@ -2,11 +2,22 @@
require 'rails_helper'
RSpec.describe Gql::Queries::User::Current::RecentView::List, type: :graphql do
RSpec.describe Gql::Queries::User::Current::RecentClose::List, type: :graphql do
let(:limit) { nil }
let(:variables) { { limit: limit } }
let(:group) { create(:group) }
let(:customer) { create(:customer) }
let(:ticket) { create(:ticket, group:, customer:) }
let(:inaccessible_customer_ticket) { create(:ticket, customer:) }
let(:user) { create(:agent, groups: [group]) }
let(:recent_close_ticket) { create(:recent_close, user:, recently_closed_object: ticket, updated_at: 1.minute.ago) }
let(:recent_close_user) { create(:recent_close, user:, recently_closed_object: customer) }
let(:recent_close_inaccessible_customer_ticket) { create(:recent_close, user: customer, recently_closed_object: inaccessible_customer_ticket) }
let(:query) do
<<~QUERY
query userCurrentRecentViewList($limit: Int) {
userCurrentRecentViewList(limit: $limit) {
query userCurrentRecentCloseList($limit: Int) {
userCurrentRecentCloseList(limit: $limit) {
__typename
... on Ticket {
id
@@ -21,23 +32,15 @@ RSpec.describe Gql::Queries::User::Current::RecentView::List, type: :graphql do
}
QUERY
end
let(:limit) { nil }
let(:variables) { { limit: limit } }
let(:group) { create(:group) }
let(:customer) { create(:customer) }
let(:ticket) { create(:ticket, group:, customer:) }
let(:inaccessible_customer_ticket) { create(:ticket, customer:) }
let(:user) { create(:agent, groups: [group]) }
before do
RecentView.log('Ticket', ticket.id, user)
RecentView.log('User', customer.id, user)
RecentView.log('Ticket', inaccessible_customer_ticket.id, user)
recent_close_ticket && recent_close_user && recent_close_inaccessible_customer_ticket
gql.execute(query, variables: variables)
end
context 'with an agent', authenticated_as: :user do
it 'returns data' do
it 'returns expected data' do
expect(gql.result.data).to eq(
[
{ '__typename' => 'User', 'id' => gql.id(customer) },

View File

@@ -2,14 +2,15 @@
require 'rails_helper'
RSpec.describe Gql::Subscriptions::User::Current::RecentView::Updates, authenticated_as: :agent, type: :graphql do
let(:mock_channel) { build_mock_channel }
let(:agent) { create(:agent) }
RSpec.describe Gql::Subscriptions::User::Current::RecentClose::Updates, authenticated_as: :agent, type: :graphql do
let(:mock_channel) { build_mock_channel }
let(:agent) { create(:agent) }
let(:subscription) do
<<~QUERY
subscription userCurrentRecentViewUpdates {
userCurrentRecentViewUpdates {
recentViewsUpdated
subscription userCurrentRecentCloseUpdates {
userCurrentRecentCloseUpdates {
recentCloseUpdated
}
}
QUERY
@@ -21,20 +22,19 @@ RSpec.describe Gql::Subscriptions::User::Current::RecentView::Updates, authentic
context 'when subscribed' do
it 'subscribes' do
expect(gql.result.data).to eq({ 'recentViewsUpdated' => nil })
expect(gql.result.data).to eq({ 'recentCloseUpdated' => nil })
end
it 'receives recent_view updates for the current user' do
RecentView.log('User', agent.id, agent)
expect(mock_channel.mock_broadcasted_messages.first.dig(:result, 'data', 'userCurrentRecentViewUpdates', 'recentViewsUpdated')).to be(true)
create(:recent_close, user: agent, recently_closed_object: agent)
expect(mock_channel.mock_broadcasted_messages.first.dig(:result, 'data', 'userCurrentRecentCloseUpdates', 'recentCloseUpdated')).to be(true)
end
context 'when another user creates updates' do
let(:other_user) { create(:agent) }
it 'does not receive recent_view updates for another user' do
UserInfo.current_user_id = other_user.id # Required to set created_by_id to this user.
RecentView.log('User', agent.id, other_user)
create(:recent_close, user: other_user, recently_closed_object: agent)
expect(mock_channel.mock_broadcasted_messages).to be_empty
end
end

View File

@@ -0,0 +1,13 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
RSpec.shared_examples 'HasRecentCloses' do
subject { create(described_class.name.underscore) }
describe '#destroy_recent_closes' do
it 'destroys recent closes' do
recent_close = create(:recent_close, recently_closed_object: subject)
subject.destroy
expect { recent_close.reload }.to raise_exception(ActiveRecord::RecordNotFound)
end
end
end

View File

@@ -10,6 +10,7 @@ require 'models/concerns/has_xss_sanitized_note_examples'
require 'models/concerns/has_image_sanitized_note_examples'
require 'models/concerns/has_object_manager_attributes_examples'
require 'models/concerns/has_taskbars_examples'
require 'models/concerns/has_recent_closes_examples'
RSpec.describe Organization, type: :model do
subject(:organization) { create(:organization) }
@@ -23,6 +24,7 @@ RSpec.describe Organization, type: :model do
it_behaves_like 'HasImageSanitizedNote', model_factory: :organization
it_behaves_like 'HasObjectManagerAttributes'
it_behaves_like 'HasTaskbars'
it_behaves_like 'HasRecentCloses'
describe 'Class methods:' do
describe '.where_or_cis' do

View File

@@ -0,0 +1,189 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe RecentClose, type: :model do
subject { create(:recent_close) }
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:recently_closed_object) }
it { is_expected.to validate_uniqueness_of(:user).scoped_to(:recently_closed_object_type, :recently_closed_object_id) }
describe '.upsert_closing_time!' do
let(:user) { create(:agent) }
let(:object) { create(:ticket) }
it 'creates a new recent close record' do
recent_close = described_class.upsert_closing_time!(user, object)
expect(recent_close).to have_attributes(
id: be_present,
user:,
recently_closed_object: object
)
end
it 'creates a new recent close if none exists' do
expect { described_class.upsert_closing_time!(user, object) }
.to change(described_class, :count).by(1)
end
it 'updates timestamp if a recent close already exists' do
existing = create(:recent_close, user:, recently_closed_object: object)
travel 1.minute
expect { described_class.upsert_closing_time!(user, object) }
.to change { existing.reload.updated_at }
end
it 'does not create a new recent close if a matching one already exists' do
create(:recent_close, user:, recently_closed_object: object)
expect { described_class.upsert_closing_time!(user, object) }
.not_to change(described_class, :count)
end
it 'creates a new recent close for the different user' do
create(:recent_close, user: create(:user), recently_closed_object: object)
expect { described_class.upsert_closing_time!(user, object) }
.to change(described_class, :count).by(1)
end
it 'creates a new recent close for the different object' do
create(:recent_close, user:, recently_closed_object: create(:ticket))
expect { described_class.upsert_closing_time!(user, object) }
.to change(described_class, :count).by(1)
end
end
describe '.cleanup' do
it 'deletes old recent closes' do
create(:recent_close)
travel 5.weeks
create(:recent_close)
travel 2.months
expect { described_class.cleanup }.to change(described_class, :count).by(-1)
end
it 'keeps old recent closes that were bumped recently' do
old_close = create(:recent_close)
travel 5.weeks
create(:recent_close)
travel 2.months
old_close.touch
expect { described_class.cleanup }.not_to change(described_class, :count)
end
end
describe '#trigger_subscriptions' do
let(:user) { create(:agent) }
it 'triggers subscription after creating' do
allow(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to receive(:trigger)
described_class.upsert_closing_time!(user, create(:ticket))
expect(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to have_received(:trigger)
.with({}, scope: user.id)
end
it 'triggers subscription after updating' do
described_class.upsert_closing_time!(user, create(:ticket))
travel 1.day
allow(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to receive(:trigger)
described_class.upsert_closing_time!(user, create(:ticket))
expect(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to have_received(:trigger)
.with({}, scope: user.id)
end
it 'trigger subscription after destroying' do
recent_close = described_class.upsert_closing_time!(user, create(:ticket))
allow(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to receive(:trigger)
recent_close.destroy
expect(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to have_received(:trigger)
.with({}, scope: user.id)
end
it 'does not trigger subscription after deleting' do
recent_close = described_class.upsert_closing_time!(user, create(:ticket))
allow(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to receive(:trigger)
recent_close.delete
expect(Gql::Subscriptions::User::Current::RecentClose::Updates)
.not_to have_received(:trigger)
end
end
describe '.destroy_logs' do
it 'deletes recent closes for the given object' do
ticket1 = create(:ticket)
ticket2 = create(:ticket)
user1 = create(:agent)
user2 = create(:agent)
_recent_close1 = create(:recent_close, recently_closed_object: ticket1, user: user1)
_recent_close2 = create(:recent_close, recently_closed_object: ticket1, user: user2)
recent_close3 = create(:recent_close, recently_closed_object: ticket2, user: user2)
described_class.destroy_logs(ticket1)
expect(described_class.all).to contain_exactly(recent_close3)
end
it 'triggers push notification with correct scope' do
ticket = create(:ticket)
user = create(:agent)
create(:recent_close, recently_closed_object: ticket, user: user)
allow(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to receive(:trigger)
described_class.destroy_logs(ticket)
expect(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to have_received(:trigger)
.with({}, scope: user.id)
end
it 'triggers push notification for each user' do
ticket = create(:ticket)
user1 = create(:agent)
user2 = create(:agent)
create(:recent_close, recently_closed_object: ticket, user: user1)
create(:recent_close, recently_closed_object: ticket, user: user2)
create(:recent_close, recently_closed_object: create(:ticket), user: user2)
allow(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to receive(:trigger)
described_class.destroy_logs(ticket)
expect(Gql::Subscriptions::User::Current::RecentClose::Updates)
.to have_received(:trigger)
.twice
end
end
end

View File

@@ -576,4 +576,33 @@ RSpec.describe Taskbar, performs_jobs: true, type: :model do
end
end
end
describe '#log_recent_close' do
before do
allow(RecentClose).to receive(:upsert_closing_time!)
end
context 'when taskbar has a related object' do
let(:ticket) { create(:ticket) }
let(:user) { create(:agent) }
let(:taskbar) { create(:taskbar, :with_ticket, ticket:, user:) }
it 'calls RecentClose.upsert_closing_time!' do
taskbar.destroy
expect(RecentClose).to have_received(:upsert_closing_time!).with(user, ticket)
end
end
context 'when taskbar has no related object' do
let(:user) { create(:agent) }
let(:taskbar) { create(:taskbar, user:) }
it 'deos not call RecentClose.upsert_closing_time!' do
taskbar.destroy
expect(RecentClose).not_to have_received(:upsert_closing_time!)
end
end
end
end

View File

@@ -9,6 +9,7 @@ require 'models/concerns/checks_core_workflow_examples'
require 'models/concerns/has_history_examples'
require 'models/concerns/has_tags_examples'
require 'models/concerns/has_taskbars_examples'
require 'models/concerns/has_recent_closes_examples'
require 'models/concerns/has_xss_sanitized_note_examples'
require 'models/concerns/has_object_manager_attributes_examples'
require 'models/tag/writes_to_ticket_history_examples'
@@ -30,6 +31,7 @@ RSpec.describe Ticket, type: :model do
it_behaves_like 'HasTags'
it_behaves_like 'TagWritesToTicketHistory'
it_behaves_like 'HasTaskbars'
it_behaves_like 'HasRecentCloses'
it_behaves_like 'HasXssSanitizedNote', model_factory: :ticket
it_behaves_like 'HasObjectManagerAttributes'
it_behaves_like 'Ticket::Escalation'

View File

@@ -15,6 +15,7 @@ require 'models/concerns/has_object_manager_attributes_examples'
require 'models/user/can_lookup_search_index_attributes_examples'
require 'models/user/performs_geo_lookup_examples'
require 'models/concerns/has_taskbars_examples'
require 'models/concerns/has_recent_closes_examples'
require 'models/concerns/has_two_factor_examples'
RSpec.describe User, type: :model do
@@ -39,6 +40,7 @@ RSpec.describe User, type: :model do
it_behaves_like 'HasObjectManagerAttributes'
it_behaves_like 'CanLookupSearchIndexAttributes'
it_behaves_like 'HasTaskbars'
it_behaves_like 'HasRecentCloses'
it_behaves_like 'UserPerformsGeoLookup'
it_behaves_like 'Association clears cache', association: :roles
it_behaves_like 'Association clears cache', association: :organizations
@@ -712,6 +714,7 @@ RSpec.describe User, type: :model do
'Chat::Agent' => { 'created_by_id' => 1, 'updated_by_id' => 1 },
'Chat::Session' => { 'user_id' => 1, 'created_by_id' => 0, 'updated_by_id' => 0 },
'Tag' => { 'created_by_id' => 0 },
'RecentClose' => { 'user_id' => 0 },
'RecentView' => { 'created_by_id' => 1 },
'KnowledgeBase::Answer::Translation' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'LdapSource' => { 'created_by_id' => 0, 'updated_by_id' => 0 },

View File

@@ -0,0 +1,44 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Service::User::ListRecentCloses do
describe '#execute' do
let(:group) { create(:group) }
let(:user) { create(:agent, groups: [group]) }
let(:ticket1) { create(:ticket, group:) }
let(:ticket2) { create(:ticket, group:) }
let(:ticket3) { create(:ticket, group:) }
let(:organization) { create(:organization) }
let(:closed_user) { create(:user) }
context 'with many recent closes' do
before do
create(:recent_close, user:, recently_closed_object: ticket1, updated_at: 2.days.ago)
create(:recent_close, user:, recently_closed_object: organization, updated_at: 1.day.ago)
create(:recent_close, user:, recently_closed_object: ticket2, updated_at: 3.days.ago)
create(:recent_close, user:, recently_closed_object: ticket3, updated_at: 3.hours.ago)
create(:recent_close, user:, recently_closed_object: closed_user, updated_at: 1.week.ago)
end
it 'returns recently closed objects in correct order' do
result = described_class.new(user).execute
expect(result).to eq([ticket3, organization, ticket1, ticket2, closed_user])
end
it 'respects the limit parameter' do
result = described_class.new(user, limit: 2).execute
expect(result).to eq([ticket3, organization])
end
it 'returns only objects the user has access to' do
expect { ticket2.update!(group: create(:group)) }
.to change { described_class.new(user).execute }
.from([ticket3, organization, ticket1, ticket2, closed_user])
.to([ticket3, organization, ticket1, closed_user])
end
end
end
end