diff --git a/backend/src/api/member/memberFind.ts b/backend/src/api/member/memberFind.ts index 9715361b96..d4e38ef6d3 100644 --- a/backend/src/api/member/memberFind.ts +++ b/backend/src/api/member/memberFind.ts @@ -20,6 +20,9 @@ export default async (req, res) => { new PermissionChecker(req).validateHas(Permissions.values.memberRead) const segmentId = req.query.segments?.length > 0 ? req.query.segments[0] : null + const includeAllAttributes = + req.query.includeAllAttributes === 'true' || req.query.includeAllAttributes === true + if (!segmentId) { await req.responseHandler.error(req, res, { code: 400, @@ -28,7 +31,12 @@ export default async (req, res) => { return } - const payload = await new MemberService(req).findById(req.params.id, segmentId, req.query.include) + const payload = await new MemberService(req).findById( + req.params.id, + segmentId, + req.query.include, + includeAllAttributes, + ) await req.responseHandler.success(req, res, payload) } diff --git a/backend/src/api/member/memberUpdate.ts b/backend/src/api/member/memberUpdate.ts index cf31b8797f..5e49c90d5a 100644 --- a/backend/src/api/member/memberUpdate.ts +++ b/backend/src/api/member/memberUpdate.ts @@ -20,9 +20,12 @@ import PermissionChecker from '../../services/user/permissionChecker' export default async (req, res) => { new PermissionChecker(req).validateHas(Permissions.values.memberEdit) - const payload = await new MemberService(req).update(req.params.id, req.body, { + const { invalidateCache, ...data } = req.body + + const payload = await new MemberService(req).update(req.params.id, data, { syncToOpensearch: true, manualChange: true, + invalidateCache: invalidateCache ?? false, }) await req.responseHandler.success(req, res, payload) diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index 7ad7a90909..12a59fb84b 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -1190,6 +1190,7 @@ class MemberRepository { segmentId?: string } = {}, include: Record = {}, + includeAllAttributes = false, ) { let memberResponse = null @@ -1201,6 +1202,7 @@ class MemberRepository { limit: 1, offset: 0, segmentId, + includeAllAttributes, include: { memberOrganizations: false, lfxMemberships: true, @@ -1219,6 +1221,7 @@ class MemberRepository { filter: { id: { eq: id } }, limit: 1, offset: 0, + includeAllAttributes, include: { lfxMemberships: true, segments: true, diff --git a/backend/src/services/memberService.ts b/backend/src/services/memberService.ts index 76d60c3923..118f455478 100644 --- a/backend/src/services/memberService.ts +++ b/backend/src/services/memberService.ts @@ -749,7 +749,7 @@ export default class MemberService extends LoggerBase { await SequelizeRepository.commitTransaction(tx) // Invalidate member query cache after unmerge - await this.invalidateMemberQueryCache([memberId, secondaryMember.id]) + await this.invalidateMemberQueryCache([memberId, secondaryMember.id], true) return { member, secondaryMember } }), @@ -1237,9 +1237,11 @@ export default class MemberService extends LoggerBase { { syncToOpensearch = true, manualChange = false, + invalidateCache = false, }: { syncToOpensearch?: boolean manualChange?: boolean + invalidateCache?: boolean } = {}, ) { let transaction @@ -1260,7 +1262,8 @@ export default class MemberService extends LoggerBase { await SequelizeRepository.commitTransaction(transaction) // Invalidate member query cache after update - await this.invalidateMemberQueryCache([id]) + // Pass invalidateCache from options to control whether to clear list caches + await this.invalidateMemberQueryCache([id], invalidateCache) const commonMemberService = new CommonMemberService( optionsQx(this.options), @@ -1315,7 +1318,8 @@ export default class MemberService extends LoggerBase { await SequelizeRepository.commitTransaction(transaction) // Invalidate member query cache after bulk delete - await this.invalidateMemberQueryCache(ids) + // Pass invalidateAll=true to also clear list caches since deletion affects list views + await this.invalidateMemberQueryCache(ids, true) } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) throw error @@ -1343,6 +1347,10 @@ export default class MemberService extends LoggerBase { } await SequelizeRepository.commitTransaction(transaction) + + // Invalidate member query cache after deletion + // Pass invalidateAll=true to also clear list caches since deletion affects list views + await this.invalidateMemberQueryCache(ids, true) } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) throw error @@ -1353,7 +1361,12 @@ export default class MemberService extends LoggerBase { } } - async findById(id, segmentId?: string, include: Record = {}) { + async findById( + id, + segmentId?: string, + include: Record = {}, + includeAllAttributes = false, + ) { return MemberRepository.findById( id, this.options, @@ -1361,6 +1374,7 @@ export default class MemberService extends LoggerBase { segmentId, }, include, + includeAllAttributes, ) } @@ -1465,16 +1479,23 @@ export default class MemberService extends LoggerBase { return fetchMemberBotSuggestionsBySegment(qx, segmentId, args.limit ?? 10, args.offset ?? 0) } - async invalidateMemberQueryCache(memberIds?: string[]): Promise { + async invalidateMemberQueryCache(memberIds?: string[], invalidateAll = false): Promise { try { const cache = new MemberQueryCache(this.options.redis) if (memberIds && memberIds.length > 0) { - // Invalidate specific member cache entries + // Invalidate specific member cache entries (queries with filter.id.eq) for (const memberId of memberIds) { await cache.invalidateByPattern(`members_advanced:${memberId}:*`) } this.log.debug(`Invalidated member query cache for ${memberIds.length} specific members`) + + // Only invalidate all caches if explicitly requested + // This is useful for operations like update/delete that affect list views + if (invalidateAll) { + await cache.invalidateAll() + this.log.debug('Invalidated all member query cache entries') + } } else { // Invalidate all cache entries await cache.invalidateAll() diff --git a/frontend/src/modules/member/components/list/member-list-toolbar.vue b/frontend/src/modules/member/components/list/member-list-toolbar.vue index 8aae013644..e2b7744017 100644 --- a/frontend/src/modules/member/components/list/member-list-toolbar.vue +++ b/frontend/src/modules/member/components/list/member-list-toolbar.vue @@ -34,6 +34,16 @@ {{ markAsTeamMemberOptions.copy }} + + + {{ markAsBotOptions.copy }} + @@ -70,6 +79,7 @@ import { useRoute } from 'vue-router'; import { storeToRefs } from 'pinia'; import pluralize from 'pluralize'; import { useMemberStore } from '@/modules/member/store/pinia'; +import { useLfSegmentsStore } from '@/modules/lf/segments/store'; import { MemberService } from '@/modules/member/member-service'; import ConfirmDialog from '@/shared/dialog/confirm-dialog'; @@ -85,22 +95,46 @@ import { EventType, FeatureEventKey } from '@/shared/modules/monitoring/types/ev import LfIcon from '@/ui-kit/icon/Icon.vue'; import LfButton from '@/ui-kit/button/Button.vue'; import LfTableBulkActions from '@/ui-kit/table/table-bulk-actions.vue'; +import { useQueryClient } from '@tanstack/vue-query'; +import { TanstackKey } from '@/shared/types/tanstack'; const { trackEvent } = useProductTracking(); const route = useRoute(); +const queryClient = useQueryClient(); const authStore = useAuthStore(); const { getUser } = authStore; const memberStore = useMemberStore(); const { selectedMembers, filters } = storeToRefs(memberStore); -const { fetchMembers } = memberStore; const { hasPermission } = usePermissions(); const bulkAttributesUpdateVisible = ref(false); +// Refresh member data by invalidating TanStack Query cache +// Note: Backend cache is invalidated by passing invalidateCache parameter to update/delete operations +const refreshMemberData = async () => { + await queryClient.invalidateQueries({ + queryKey: [TanstackKey.MEMBERS_LIST], + }); +}; + +// Helper function to fetch member with all attributes before bulk update +const fetchMemberWithAllAttributes = async (memberId) => { + const lsSegmentsStore = useLfSegmentsStore(); + const { selectedProjectGroup } = storeToRefs(lsSegmentsStore); + + const response = await MemberService.find( + memberId, + selectedProjectGroup.value?.id, + true, + ); + + return response; +}; + const markAsTeamMemberOptions = computed(() => { const isTeamView = filters.value.settings.teamMember === 'filter'; const membersCopy = pluralize( @@ -124,6 +158,31 @@ const markAsTeamMemberOptions = computed(() => { }; }); +const markAsBotOptions = computed(() => { + const membersCopy = pluralize( + 'person', + selectedMembers.value.length, + false, + ); + + // Check if any of the selected members is already marked as bot + const hasBot = selectedMembers.value.some((member) => member.attributes?.isBot?.default); + + if (hasBot) { + return { + icon: 'robot', + copy: 'Unmark as bot', + value: false, + }; + } + + return { + icon: 'robot', + copy: 'Mark as bot', + value: true, + }; +}); + const handleMergeMembers = async () => { const [firstMember, secondMember] = selectedMembers.value; @@ -168,7 +227,13 @@ const doDestroyAllWithConfirm = () => ConfirmDialog({ const ids = selectedMembers.value.map((m) => m.id); return MemberService.destroyAll(ids); }) - .then(() => fetchMembers({ reload: true })); + .then(async () => { + // Clear selection immediately to prevent UI issues + selectedMembers.value = []; + + // Refresh data to ensure UI is up to date + await refreshMemberData(); + }); const handleDoExport = async () => { const ids = selectedMembers.value.map((i) => i.id); @@ -226,20 +291,77 @@ const handleEditAttribute = async () => { const doMarkAsTeamMember = async (value) => { ToastStore.info('People are being updated'); - return Promise.all(selectedMembers.value.map((member) => MemberService.update(member.id, { - attributes: { - ...member.attributes, + const updatePromises = selectedMembers.value.map(async (member) => { + // Fetch member with all attributes to prevent data loss + const memberWithAllAttributes = await fetchMemberWithAllAttributes(member.id); + const currentAttributes = memberWithAllAttributes.attributes; + + const updatedAttributes = { + ...currentAttributes, isTeamMember: { default: value, + custom: value, }, - }, - }, member.segmentIds))) - .then(() => { + }; + + return MemberService.update(member.id, { + attributes: updatedAttributes, + invalidateCache: true, + }); + }); + + return Promise.all(updatePromises) + .then(async () => { + ToastStore.closeAll(); + ToastStore.success(`${ + pluralize('Person', selectedMembers.value.length, true)} updated successfully`); + + // Clear selection immediately to prevent UI issues + selectedMembers.value = []; + + // Refresh data to ensure UI is up to date + await refreshMemberData(); + }) + .catch(() => { + ToastStore.closeAll(); + ToastStore.error('Error updating people'); + }); +}; + +const doMarkAsBot = async (value) => { + ToastStore.info('People are being updated'); + + const updatePromises = selectedMembers.value.map(async (member) => { + // Fetch member with all attributes to prevent data loss + const memberWithAllAttributes = await fetchMemberWithAllAttributes(member.id); + const currentAttributes = memberWithAllAttributes.attributes; + + const updatedAttributes = { + ...currentAttributes, + isBot: { + ...currentAttributes.isBot, + default: value, + custom: value, + }, + }; + + return MemberService.update(member.id, { + attributes: updatedAttributes, + invalidateCache: true, + }); + }); + + return Promise.all(updatePromises) + .then(async () => { ToastStore.closeAll(); ToastStore.success(`${ pluralize('Person', selectedMembers.value.length, true)} updated successfully`); - fetchMembers({ reload: true }); + // Clear selection immediately to prevent UI issues + selectedMembers.value = []; + + // Refresh data to ensure UI is up to date + await refreshMemberData(); }) .catch(() => { ToastStore.closeAll(); @@ -259,6 +381,17 @@ const handleCommand = async (command) => { }); await doMarkAsTeamMember(command.value); + } else if (command.action === 'markAsBot') { + trackEvent({ + key: FeatureEventKey.MARK_AS_BOT, + type: EventType.FEATURE, + properties: { + path: route.path, + bulk: true, + }, + }); + + await doMarkAsBot(command.value); } else if (command.action === 'export') { await handleDoExport(); } else if (command.action === 'mergeMembers') { diff --git a/frontend/src/modules/member/components/member-dropdown-content.vue b/frontend/src/modules/member/components/member-dropdown-content.vue index 6a8c194767..27e8e340a3 100644 --- a/frontend/src/modules/member/components/member-dropdown-content.vue +++ b/frontend/src/modules/member/components/member-dropdown-content.vue @@ -156,7 +156,6 @@ import { MemberService } from '@/modules/member/member-service'; import { ToastStore } from '@/shared/message/notification'; import ConfirmDialog from '@/shared/dialog/confirm-dialog'; -import { useMemberStore } from '@/modules/member/store/pinia'; import { useRoute } from 'vue-router'; import { computed } from 'vue'; import { storeToRefs } from 'pinia'; @@ -166,6 +165,8 @@ import { LfPermission } from '@/shared/modules/permissions/types/Permissions'; import useProductTracking from '@/shared/modules/monitoring/useProductTracking'; import { EventType, FeatureEventKey } from '@/shared/modules/monitoring/types/event'; import LfIcon from '@/ui-kit/icon/Icon.vue'; +import { useQueryClient } from '@tanstack/vue-query'; +import { TanstackKey } from '@/shared/types/tanstack'; import { Member } from '../types/Member'; enum Actions { @@ -189,6 +190,7 @@ const props = defineProps<{ }>(); const route = useRoute(); +const queryClient = useQueryClient(); const { doFind } = mapActions('member'); @@ -196,14 +198,31 @@ const { trackEvent } = useProductTracking(); const { selectedProjectGroup } = storeToRefs(useLfSegmentsStore()); -const memberStore = useMemberStore(); - const { hasPermission } = usePermissions(); const isFindingGitHubDisabled = computed(() => ( !!props.member.username?.github )); +// Refresh member data by invalidating TanStack Query cache +// Note: Backend cache is invalidated by passing invalidateCache parameter to update/delete operations +const refreshMemberData = async () => { + await queryClient.invalidateQueries({ + queryKey: [TanstackKey.MEMBERS_LIST], + }); +}; + +// Helper function to fetch member with all attributes before update +const fetchMemberWithAllAttributes = async (memberId: string) => { + const response = await MemberService.find( + memberId, + selectedProjectGroup.value?.id, + true, + ); + + return response; +}; + const doManualAction = async ({ loadingMessage, actionFn, @@ -266,8 +285,8 @@ const handleCommand = async (command: { successMessage: 'Profile successfully deleted', errorMessage: 'Something went wrong', actionFn: MemberService.destroyAll([command.member.id]), - }).then(() => { - memberStore.fetchMembers({ reload: true }); + }).then(async () => { + await refreshMemberData(); }); }); @@ -292,9 +311,9 @@ const handleCommand = async (command: { actionFn: isSyncing ? HubspotApiService.syncMember(command.member.id) : HubspotApiService.stopSyncMember(command.member.id), - }).then(() => { + }).then(async () => { if (route.name === 'member') { - memberStore.fetchMembers({ reload: true }); + await refreshMemberData(); } else { doFind({ id: command.member.id, @@ -316,28 +335,26 @@ const handleCommand = async (command: { }, }); + // Fetch member with all attributes to prevent data loss + const memberWithAllAttributes = await fetchMemberWithAllAttributes(command.member.id); + const currentAttributes = memberWithAllAttributes.attributes; + doManualAction({ loadingMessage: 'Profile is being updated', successMessage: 'Profile updated successfully', errorMessage: 'Something went wrong', actionFn: MemberService.update(command.member.id, { attributes: { - ...command.member.attributes, + ...currentAttributes, isTeamMember: { default: command.value, custom: command.value, }, }, + invalidateCache: true, }), - }).then(() => { - if (route.name === 'member') { - memberStore.fetchMembers({ reload: true }); - } else { - doFind({ - id: command.member.id, - segments: [selectedProjectGroup.value?.id], - }); - } + }).then(async () => { + await refreshMemberData(); }); return; @@ -356,28 +373,29 @@ const handleCommand = async (command: { }, }); + // Fetch member with all attributes to prevent data loss + const memberWithAllAttributes = await fetchMemberWithAllAttributes(command.member.id); + const currentAttributes = memberWithAllAttributes.attributes; + + const isBot = command.action === Actions.MARK_CONTACT_AS_BOT; + doManualAction({ loadingMessage: 'Profile is being updated', successMessage: 'Profile updated successfully', errorMessage: 'Something went wrong', actionFn: MemberService.update(command.member.id, { attributes: { - ...command.member.attributes, + ...currentAttributes, isBot: { - default: command.action === Actions.MARK_CONTACT_AS_BOT, - custom: command.action === Actions.MARK_CONTACT_AS_BOT, + ...currentAttributes.isBot, + default: isBot, + custom: isBot, }, }, + invalidateCache: true, }), - }).then(() => { - if (route.name === 'member') { - memberStore.fetchMembers({ reload: true }); - } else { - doFind({ - id: command.member.id, - segments: command.member.segments.map((s) => s.id), - }); - } + }).then(async () => { + await refreshMemberData(); }); return; diff --git a/frontend/src/modules/member/member-service.js b/frontend/src/modules/member/member-service.js index a00c6d3063..a025b4d708 100644 --- a/frontend/src/modules/member/member-service.js +++ b/frontend/src/modules/member/member-service.js @@ -86,10 +86,11 @@ export class MemberService { return response.data; } - static async find(id, segmentId) { + static async find(id, segmentId, includeAllAttributes = false) { const response = await authAxios.get(`/member/${id}`, { params: { segments: [segmentId ?? getSelectedProjectGroup().id], + includeAllAttributes, include: { identities: true, memberOrganizations: true, diff --git a/frontend/src/modules/member/pages/member-list-page.vue b/frontend/src/modules/member/pages/member-list-page.vue index 3e51b81d51..6ea7881921 100644 --- a/frontend/src/modules/member/pages/member-list-page.vue +++ b/frontend/src/modules/member/pages/member-list-page.vue @@ -206,12 +206,23 @@ watch(membersData, (newData) => { // Update the Pinia store with the new data memberStore.members = newData.rows || []; memberStore.totalMembers = newData.count || 0; + + // Build the correct transformed filter for savedFilterBody + const transformedFilter = filters.value + ? buildApiFilter( + filters.value, + { ...memberFilters, ...customAttributesFilter.value }, + memberSearchFilter, + memberSavedViews, + ) + : { search: '', filter: {}, orderBy: 'activityCount_DESC' }; + memberStore.savedFilterBody = { search: queryParams.value.search, - filter: filters.value, + filter: transformedFilter.filter, offset: queryParams.value.offset, limit: queryParams.value.limit, - orderBy: queryParams.value.orderBy, + orderBy: transformedFilter.orderBy, }; } }, { immediate: true }); diff --git a/services/libs/data-access-layer/src/members/base.ts b/services/libs/data-access-layer/src/members/base.ts index 88d609e22d..4f493389a0 100644 --- a/services/libs/data-access-layer/src/members/base.ts +++ b/services/libs/data-access-layer/src/members/base.ts @@ -41,6 +41,7 @@ interface IQueryMembersAdvancedParams { segmentId?: string countOnly?: boolean fields?: string[] + includeAllAttributes?: boolean include?: { identities?: boolean segments?: boolean @@ -148,6 +149,7 @@ export async function queryMembersAdvanced( segmentId = undefined, countOnly = false, fields = [...QUERY_FILTER_COLUMN_MAP.keys()], + includeAllAttributes = false, include = { identities: true, segments: false, @@ -175,6 +177,7 @@ export async function queryMembersAdvanced( fields, filter, include, + includeAllAttributes, limit, offset, orderBy, @@ -184,7 +187,7 @@ export async function queryMembersAdvanced( // Try to get from cache first const cachedResult = countOnly ? null : await cache.get(cacheKey) - const cachedCount = countOnly ? await cache.getCount(cacheKey) : null + const cachedCount = countOnly ? null : await cache.getCount(cacheKey) if (cachedResult) { refreshCacheInBackground(bgQx, redis, cacheKey, { @@ -197,6 +200,7 @@ export async function queryMembersAdvanced( countOnly: false, fields, include, + includeAllAttributes, attributeSettings, }) @@ -210,6 +214,7 @@ export async function queryMembersAdvanced( search, segmentId, include, + includeAllAttributes, attributeSettings, }) @@ -232,6 +237,7 @@ export async function queryMembersAdvanced( countOnly, fields, include, + includeAllAttributes, attributeSettings, }) } @@ -249,6 +255,7 @@ export async function executeQuery( segmentId = undefined, countOnly = false, fields = [...QUERY_FILTER_COLUMN_MAP.keys()], + includeAllAttributes = false, include = { identities: true, segments: false, @@ -474,14 +481,33 @@ export async function executeQuery( for (const member of rows) { if (member.attributes) { + // Always include default attributes for optimization const { isBot, jobTitle, avatarUrl, isTeamMember } = member.attributes - member.attributes = { + const defaultAttributes = { ...(isBot !== undefined && { isBot }), ...(jobTitle !== undefined && { jobTitle }), ...(avatarUrl !== undefined && { avatarUrl }), ...(isTeamMember !== undefined && { isTeamMember }), } + + if (includeAllAttributes) { + // When includeAllAttributes is true, add additional attributes to prevent data loss during updates + const { bio, url, company, location, isHireable, websiteUrl } = member.attributes + + member.attributes = { + ...defaultAttributes, + ...(bio !== undefined && { bio }), + ...(url !== undefined && { url }), + ...(company !== undefined && { company }), + ...(location !== undefined && { location }), + ...(isHireable !== undefined && { isHireable }), + ...(websiteUrl !== undefined && { websiteUrl }), + } + } else { + // Default behavior: only commonly used attributes for list views + member.attributes = defaultAttributes + } } } diff --git a/services/libs/data-access-layer/src/members/queryCache.ts b/services/libs/data-access-layer/src/members/queryCache.ts index c09938f0ca..ba9ceda98d 100644 --- a/services/libs/data-access-layer/src/members/queryCache.ts +++ b/services/libs/data-access-layer/src/members/queryCache.ts @@ -30,6 +30,7 @@ export class MemberQueryCache { fields?: string[] filter?: Record include?: IncludeOptions + includeAllAttributes?: boolean limit: number offset: number orderBy?: string @@ -42,6 +43,7 @@ export class MemberQueryCache { fields: params.fields?.sort(), filter: params.filter, include: params.include, + includeAllAttributes: params.includeAllAttributes, limit: params.limit, offset: params.offset, orderBy: params.orderBy,