From d12d3e1ec792f6beedce0c94cd731e43a2ef8dae Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Mon, 30 Mar 2026 18:18:46 +0800 Subject: [PATCH] redis pt 2: read cache wiring & event based invalidations --- src/auth/auth.service.ts | 15 ++++- src/common/cache/cache-keys.ts | 61 +++++++++++++++++ src/common/cache/cache-tags.service.ts | 66 +++++++++++++++++++ src/common/cache/cache.module.ts | 5 +- src/dolls/dolls-cache-invalidation.service.ts | 37 +++++++++++ src/dolls/dolls.module.ts | 7 +- src/dolls/dolls.service.ts | 53 +++++++++++++++ .../friends-cache-invalidation.service.ts | 65 ++++++++++++++++++ src/friends/friends.module.ts | 7 +- src/friends/friends.service.ts | 57 +++++++++++++++- src/users/events/user.events.ts | 5 ++ src/users/users-cache-invalidation.service.ts | 38 +++++++++++ src/users/users.module.ts | 7 +- src/users/users.service.ts | 60 ++++++++++++++++- 14 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 src/common/cache/cache-keys.ts create mode 100644 src/common/cache/cache-tags.service.ts create mode 100644 src/dolls/dolls-cache-invalidation.service.ts create mode 100644 src/friends/friends-cache-invalidation.service.ts create mode 100644 src/users/users-cache-invalidation.service.ts diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index eecda4e..64d173c 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -13,6 +13,7 @@ import { verify, } from 'jsonwebtoken'; import { PrismaService } from '../database/prisma.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import type { SocialAuthProfile } from './types/social-auth-profile'; import type { AuthTokens, @@ -35,6 +36,7 @@ import { usernameFromEmail, } from './auth.utils'; import type { SsoProvider } from './dto/sso-provider'; +import { UserEvents } from '../users/events/user.events'; interface SsoStateClaims { provider: SsoProvider; @@ -56,6 +58,7 @@ export class AuthService { constructor( private readonly prisma: PrismaService, private readonly configService: ConfigService, + private readonly eventEmitter: EventEmitter2, ) { this.jwtSecret = this.configService.get('JWT_SECRET') || ''; this.jwtIssuer = @@ -254,6 +257,10 @@ export class AuthService { }, }); + this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, { + userId: user.id, + }); + return user; } @@ -273,7 +280,7 @@ export class AuthService { ); } - return this.prisma.$transaction(async (tx) => { + const user = await this.prisma.$transaction(async (tx) => { let user = await tx.user.findUnique({ where: { email }, }); @@ -311,6 +318,12 @@ export class AuthService { return user; }); + + this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, { + userId: user.id, + }); + + return user; } private async resolveUsername( diff --git a/src/common/cache/cache-keys.ts b/src/common/cache/cache-keys.ts new file mode 100644 index 0000000..3ee308d --- /dev/null +++ b/src/common/cache/cache-keys.ts @@ -0,0 +1,61 @@ +const EMPTY_VALUE_TOKEN = '_'; + +export const CACHE_NAMESPACE = { + FRIENDS_LIST: 'friends-list', + DOLLS_LIST: 'dolls-list', + USERS_SEARCH: 'users-search', +} as const; + +function normalizeKeyPart(value: string | undefined): string { + if (!value) { + return EMPTY_VALUE_TOKEN; + } + + return encodeURIComponent(value); +} + +export const CACHE_TTL_SECONDS = { + FRIENDS_LIST: 30, + DOLLS_LIST: 30, + USERS_SEARCH: 20, +} as const; + +export function friendsListCacheKey(userId: string): string { + return normalizeKeyPart(userId); +} + +export function friendsListOwnerTag(userId: string): string { + return `owner:${normalizeKeyPart(userId)}`; +} + +export function friendsListDependsOnUserTag(userId: string): string { + return `depends-on:${normalizeKeyPart(userId)}`; +} + +export function dollsListCacheKey( + ownerId: string, + requesterId: string, +): string { + return `${normalizeKeyPart(ownerId)}:${normalizeKeyPart(requesterId)}`; +} + +export function dollsListOwnerTag(ownerId: string): string { + return `owner:${normalizeKeyPart(ownerId)}`; +} + +export function dollsListViewerTag(viewerId: string): string { + return `viewer:${normalizeKeyPart(viewerId)}`; +} + +export function usersSearchCacheKey( + username: string | undefined, + excludeUserId: string | undefined, +): string { + return `${normalizeKeyPart(username?.trim().toLowerCase())}:${normalizeKeyPart(excludeUserId)}`; +} + +export const USERS_SEARCH_GLOBAL_TAG = 'global'; + +export function usersSearchUserTag(userId: string): string { + return `user:${normalizeKeyPart(userId)}`; +} diff --git a/src/common/cache/cache-tags.service.ts b/src/common/cache/cache-tags.service.ts new file mode 100644 index 0000000..05abc2e --- /dev/null +++ b/src/common/cache/cache-tags.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { CacheService } from './cache.service'; + +const CACHE_TAG_SET_TTL_SECONDS = 86_400; + +@Injectable() +export class CacheTagsService { + constructor(private readonly cacheService: CacheService) {} + + async rememberKeyForTag( + namespace: string, + tag: string, + cacheKey: string, + ): Promise { + const redisClient = this.cacheService.getRedisClient(); + if (!redisClient) { + return; + } + + const tagSetKey = this.getTagSetKey(namespace, tag); + const keyWithNamespace = this.cacheService.getNamespacedKey( + namespace, + cacheKey, + ); + + try { + await Promise.all([ + redisClient.sadd(tagSetKey, keyWithNamespace), + redisClient.expire(tagSetKey, CACHE_TAG_SET_TTL_SECONDS), + ]); + } catch (error) { + this.cacheService.recordError('tag remember', tagSetKey, error); + } + } + + async invalidateTag(namespace: string, tag: string): Promise { + const redisClient = this.cacheService.getRedisClient(); + if (!redisClient) { + return; + } + + const tagSetKey = this.getTagSetKey(namespace, tag); + + try { + const keys = await redisClient.smembers(tagSetKey); + if (keys.length === 0) { + await redisClient.del(tagSetKey); + return; + } + + const pipeline = redisClient.pipeline(); + keys.forEach((key) => pipeline.del(key)); + pipeline.del(tagSetKey); + await pipeline.exec(); + } catch (error) { + this.cacheService.recordError('tag invalidate', tagSetKey, error); + } + } + + private getTagSetKey(namespace: string, tag: string): string { + return this.cacheService.getNamespacedKey( + 'cache-tag', + `${namespace}:${tag}`, + ); + } +} diff --git a/src/common/cache/cache.module.ts b/src/common/cache/cache.module.ts index 53ed337..72e8425 100644 --- a/src/common/cache/cache.module.ts +++ b/src/common/cache/cache.module.ts @@ -1,12 +1,13 @@ import { Global, Module } from '@nestjs/common'; import { RedisModule } from '../../database/redis.module'; +import { CacheTagsService } from './cache-tags.service'; import { CacheService } from './cache.service'; import { RedisThrottlerStorage } from './redis-throttler.storage'; @Global() @Module({ imports: [RedisModule], - providers: [CacheService, RedisThrottlerStorage], - exports: [CacheService, RedisThrottlerStorage], + providers: [CacheService, CacheTagsService, RedisThrottlerStorage], + exports: [CacheService, CacheTagsService, RedisThrottlerStorage], }) export class CacheModule {} diff --git a/src/dolls/dolls-cache-invalidation.service.ts b/src/dolls/dolls-cache-invalidation.service.ts new file mode 100644 index 0000000..628c9e9 --- /dev/null +++ b/src/dolls/dolls-cache-invalidation.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { CacheTagsService } from '../common/cache/cache-tags.service'; +import { CACHE_NAMESPACE, dollsListOwnerTag } from '../common/cache/cache-keys'; +import { DollEvents } from './events/doll.events'; +import { + type DollCreatedEvent, + type DollDeletedEvent, + type DollUpdatedEvent, +} from './events/doll.events'; + +@Injectable() +export class DollsCacheInvalidationService { + constructor(private readonly cacheTagsService: CacheTagsService) {} + + @OnEvent(DollEvents.DOLL_CREATED) + async handleDollCreated(payload: DollCreatedEvent): Promise { + await this.invalidateOwnerLists(payload.userId); + } + + @OnEvent(DollEvents.DOLL_UPDATED) + async handleDollUpdated(payload: DollUpdatedEvent): Promise { + await this.invalidateOwnerLists(payload.userId); + } + + @OnEvent(DollEvents.DOLL_DELETED) + async handleDollDeleted(payload: DollDeletedEvent): Promise { + await this.invalidateOwnerLists(payload.userId); + } + + private async invalidateOwnerLists(ownerId: string): Promise { + await this.cacheTagsService.invalidateTag( + CACHE_NAMESPACE.DOLLS_LIST, + dollsListOwnerTag(ownerId), + ); + } +} diff --git a/src/dolls/dolls.module.ts b/src/dolls/dolls.module.ts index f31dff7..9eb9a76 100644 --- a/src/dolls/dolls.module.ts +++ b/src/dolls/dolls.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { DollsService } from './dolls.service'; import { DollsController } from './dolls.controller'; +import { DollsCacheInvalidationService } from './dolls-cache-invalidation.service'; import { DollsNotificationService } from './dolls-notification.service'; import { DatabaseModule } from '../database/database.module'; import { AuthModule } from '../auth/auth.module'; @@ -9,7 +10,11 @@ import { WsModule } from '../ws/ws.module'; @Module({ imports: [DatabaseModule, AuthModule, forwardRef(() => WsModule)], controllers: [DollsController], - providers: [DollsService, DollsNotificationService], + providers: [ + DollsService, + DollsNotificationService, + DollsCacheInvalidationService, + ], exports: [DollsService], }) export class DollsModule {} diff --git a/src/dolls/dolls.service.ts b/src/dolls/dolls.service.ts index 687e5c1..d1caa7a 100644 --- a/src/dolls/dolls.service.ts +++ b/src/dolls/dolls.service.ts @@ -15,6 +15,15 @@ import { DollUpdatedEvent, DollDeletedEvent, } from './events/doll.events'; +import { CacheService } from '../common/cache/cache.service'; +import { CacheTagsService } from '../common/cache/cache-tags.service'; +import { + CACHE_NAMESPACE, + CACHE_TTL_SECONDS, + dollsListCacheKey, + dollsListOwnerTag, + dollsListViewerTag, +} from '../common/cache/cache-keys'; @Injectable() export class DollsService { @@ -23,6 +32,8 @@ export class DollsService { constructor( private readonly prisma: PrismaService, private readonly eventEmitter: EventEmitter2, + private readonly cacheService: CacheService, + private readonly cacheTagsService: CacheTagsService, ) {} async getFriendIds(userId: string): Promise { @@ -76,6 +87,48 @@ export class DollsService { async listByOwner( ownerId: string, requestingUserId: string, + ): Promise { + const cacheKey = dollsListCacheKey(ownerId, requestingUserId); + const namespacedKey = this.cacheService.getNamespacedKey( + CACHE_NAMESPACE.DOLLS_LIST, + cacheKey, + ); + const cached = await this.cacheService.get(namespacedKey); + + if (cached) { + try { + return JSON.parse(cached) as Doll[]; + } catch (error) { + this.cacheService.recordError('dolls list parse', namespacedKey, error); + } + } + + const dolls = await this.listByOwnerFromDatabase(ownerId, requestingUserId); + + await this.cacheService.set( + namespacedKey, + JSON.stringify(dolls), + CACHE_TTL_SECONDS.DOLLS_LIST, + ); + await Promise.all([ + this.cacheTagsService.rememberKeyForTag( + CACHE_NAMESPACE.DOLLS_LIST, + dollsListOwnerTag(ownerId), + cacheKey, + ), + this.cacheTagsService.rememberKeyForTag( + CACHE_NAMESPACE.DOLLS_LIST, + dollsListViewerTag(requestingUserId), + cacheKey, + ), + ]); + + return dolls; + } + + private async listByOwnerFromDatabase( + ownerId: string, + requestingUserId: string, ): Promise { // If requesting own dolls, no need to check friendship if (ownerId === requestingUserId) { diff --git a/src/friends/friends-cache-invalidation.service.ts b/src/friends/friends-cache-invalidation.service.ts new file mode 100644 index 0000000..b9299e9 --- /dev/null +++ b/src/friends/friends-cache-invalidation.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { CacheTagsService } from '../common/cache/cache-tags.service'; +import { + CACHE_NAMESPACE, + dollsListViewerTag, + friendsListDependsOnUserTag, + friendsListOwnerTag, +} from '../common/cache/cache-keys'; +import { FriendEvents } from './events/friend.events'; +import type { + FriendRequestAcceptedEvent, + UnfriendedEvent, +} from './events/friend.events'; + +@Injectable() +export class FriendsCacheInvalidationService { + constructor(private readonly cacheTagsService: CacheTagsService) {} + + @OnEvent(FriendEvents.REQUEST_ACCEPTED) + async handleFriendAccepted( + payload: FriendRequestAcceptedEvent, + ): Promise { + const senderId = payload.friendRequest.senderId; + const receiverId = payload.friendRequest.receiverId; + await this.invalidateFriendAndDollViews(senderId, receiverId); + } + + @OnEvent(FriendEvents.UNFRIENDED) + async handleUnfriended(payload: UnfriendedEvent): Promise { + await this.invalidateFriendAndDollViews(payload.userId, payload.friendId); + } + + private async invalidateFriendAndDollViews( + firstUserId: string, + secondUserId: string, + ): Promise { + await Promise.all([ + this.cacheTagsService.invalidateTag( + CACHE_NAMESPACE.FRIENDS_LIST, + friendsListOwnerTag(firstUserId), + ), + this.cacheTagsService.invalidateTag( + CACHE_NAMESPACE.FRIENDS_LIST, + friendsListOwnerTag(secondUserId), + ), + this.cacheTagsService.invalidateTag( + CACHE_NAMESPACE.FRIENDS_LIST, + friendsListDependsOnUserTag(firstUserId), + ), + this.cacheTagsService.invalidateTag( + CACHE_NAMESPACE.FRIENDS_LIST, + friendsListDependsOnUserTag(secondUserId), + ), + this.cacheTagsService.invalidateTag( + CACHE_NAMESPACE.DOLLS_LIST, + dollsListViewerTag(firstUserId), + ), + this.cacheTagsService.invalidateTag( + CACHE_NAMESPACE.DOLLS_LIST, + dollsListViewerTag(secondUserId), + ), + ]); + } +} diff --git a/src/friends/friends.module.ts b/src/friends/friends.module.ts index 88741de..b0e77ec 100644 --- a/src/friends/friends.module.ts +++ b/src/friends/friends.module.ts @@ -1,5 +1,6 @@ import { Module, forwardRef } from '@nestjs/common'; import { FriendsController } from './friends.controller'; +import { FriendsCacheInvalidationService } from './friends-cache-invalidation.service'; import { FriendsService } from './friends.service'; import { FriendsNotificationService } from './friends-notification.service'; import { DatabaseModule } from '../database/database.module'; @@ -15,7 +16,11 @@ import { WsModule } from '../ws/ws.module'; forwardRef(() => WsModule), ], controllers: [FriendsController], - providers: [FriendsService, FriendsNotificationService], + providers: [ + FriendsService, + FriendsNotificationService, + FriendsCacheInvalidationService, + ], exports: [FriendsService], }) export class FriendsModule {} diff --git a/src/friends/friends.service.ts b/src/friends/friends.service.ts index 20d2e3b..521dc7b 100644 --- a/src/friends/friends.service.ts +++ b/src/friends/friends.service.ts @@ -15,6 +15,15 @@ import { FriendRequestDeniedEvent, UnfriendedEvent, } from './events/friend.events'; +import { CacheService } from '../common/cache/cache.service'; +import { CacheTagsService } from '../common/cache/cache-tags.service'; +import { + CACHE_NAMESPACE, + CACHE_TTL_SECONDS, + friendsListCacheKey, + friendsListDependsOnUserTag, + friendsListOwnerTag, +} from '../common/cache/cache-keys'; export type FriendRequestWithRelations = FriendRequest & { sender: User; @@ -28,6 +37,8 @@ export class FriendsService { constructor( private readonly prisma: PrismaService, private readonly eventEmitter: EventEmitter2, + private readonly cacheService: CacheService, + private readonly cacheTagsService: CacheTagsService, ) {} async sendFriendRequest( @@ -272,7 +283,28 @@ export class FriendsService { } async getFriends(userId: string) { - return this.prisma.friendship.findMany({ + const cacheKey = friendsListCacheKey(userId); + const namespacedKey = this.cacheService.getNamespacedKey( + CACHE_NAMESPACE.FRIENDS_LIST, + cacheKey, + ); + const cached = await this.cacheService.get(namespacedKey); + + if (cached) { + try { + return JSON.parse(cached) as Awaited< + ReturnType + >; + } catch (error) { + this.cacheService.recordError( + 'friends list parse', + namespacedKey, + error, + ); + } + } + + const friendships = await this.prisma.friendship.findMany({ where: { userId }, include: { friend: { @@ -285,6 +317,29 @@ export class FriendsService { createdAt: 'desc', }, }); + + await this.cacheService.set( + namespacedKey, + JSON.stringify(friendships), + CACHE_TTL_SECONDS.FRIENDS_LIST, + ); + + const dependentFriendTags = friendships.map((friendship) => + friendsListDependsOnUserTag(friendship.friendId), + ); + const tags = [friendsListOwnerTag(userId), ...dependentFriendTags]; + + await Promise.all( + tags.map((tag) => + this.cacheTagsService.rememberKeyForTag( + CACHE_NAMESPACE.FRIENDS_LIST, + tag, + cacheKey, + ), + ), + ); + + return friendships; } async unfriend(userId: string, friendId: string): Promise { diff --git a/src/users/events/user.events.ts b/src/users/events/user.events.ts index 4615135..0e7835c 100644 --- a/src/users/events/user.events.ts +++ b/src/users/events/user.events.ts @@ -2,6 +2,7 @@ import { Doll } from '@prisma/client'; export const UserEvents = { ACTIVE_DOLL_CHANGED: 'user.active-doll.changed', + SEARCH_INDEX_INVALIDATED: 'user.search-index.invalidated', } as const; export interface UserActiveDollChangedEvent { @@ -9,3 +10,7 @@ export interface UserActiveDollChangedEvent { dollId: string | null; doll: Doll | null; } + +export interface UserSearchIndexInvalidatedEvent { + userId?: string; +} diff --git a/src/users/users-cache-invalidation.service.ts b/src/users/users-cache-invalidation.service.ts new file mode 100644 index 0000000..00038b9 --- /dev/null +++ b/src/users/users-cache-invalidation.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { CacheTagsService } from '../common/cache/cache-tags.service'; +import { + CACHE_NAMESPACE, + USERS_SEARCH_GLOBAL_TAG, + usersSearchUserTag, +} from '../common/cache/cache-keys'; +import { UserEvents } from './events/user.events'; +import type { UserSearchIndexInvalidatedEvent } from './events/user.events'; + +@Injectable() +export class UsersCacheInvalidationService { + constructor(private readonly cacheTagsService: CacheTagsService) {} + + @OnEvent(UserEvents.SEARCH_INDEX_INVALIDATED) + async handleSearchIndexInvalidation( + payload: UserSearchIndexInvalidatedEvent, + ): Promise { + const tasks: Promise[] = [ + this.cacheTagsService.invalidateTag( + CACHE_NAMESPACE.USERS_SEARCH, + USERS_SEARCH_GLOBAL_TAG, + ), + ]; + + if (payload.userId) { + tasks.push( + this.cacheTagsService.invalidateTag( + CACHE_NAMESPACE.USERS_SEARCH, + usersSearchUserTag(payload.userId), + ), + ); + } + + await Promise.all(tasks); + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 6273969..89fe98a 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,5 +1,6 @@ import { Module, forwardRef } from '@nestjs/common'; import { UsersService } from './users.service'; +import { UsersCacheInvalidationService } from './users-cache-invalidation.service'; import { UsersController } from './users.controller'; import { UsersNotificationService } from './users-notification.service'; import { AuthModule } from '../auth/auth.module'; @@ -16,7 +17,11 @@ import { WsModule } from '../ws/ws.module'; */ @Module({ imports: [forwardRef(() => AuthModule), forwardRef(() => WsModule)], - providers: [UsersService, UsersNotificationService], + providers: [ + UsersService, + UsersNotificationService, + UsersCacheInvalidationService, + ], controllers: [UsersController], exports: [UsersService], }) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index df1d310..b59ad71 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -10,6 +10,15 @@ import { User, Prisma } from '@prisma/client'; import type { UpdateUserDto } from './dto/update-user.dto'; import { UserEvents } from './events/user.events'; import { normalizeEmail } from '../auth/auth.utils'; +import { CacheService } from '../common/cache/cache.service'; +import { CacheTagsService } from '../common/cache/cache-tags.service'; +import { + CACHE_NAMESPACE, + CACHE_TTL_SECONDS, + usersSearchUserTag, + usersSearchCacheKey, + USERS_SEARCH_GLOBAL_TAG, +} from '../common/cache/cache-keys'; export interface CreateLocalUserDto { email: string; @@ -30,6 +39,8 @@ export class UsersService { constructor( private readonly prisma: PrismaService, private readonly eventEmitter: EventEmitter2, + private readonly cacheService: CacheService, + private readonly cacheTagsService: CacheTagsService, ) {} // Legacy Keycloak user creation removed in favor of local auth. @@ -92,6 +103,8 @@ export class UsersService { data: updateData, }); + this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, { userId: id }); + this.logger.log(`User ${id} profile update requested`); return updatedUser; @@ -121,6 +134,8 @@ export class UsersService { where: { id }, }); + this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, { userId: id }); + this.logger.log(`User ${id} deleted their account`); } @@ -128,6 +143,25 @@ export class UsersService { username?: string, excludeUserId?: string, ): Promise { + const cacheKey = usersSearchCacheKey(username, excludeUserId); + const namespacedKey = this.cacheService.getNamespacedKey( + CACHE_NAMESPACE.USERS_SEARCH, + cacheKey, + ); + const cached = await this.cacheService.get(namespacedKey); + + if (cached) { + try { + return JSON.parse(cached) as User[]; + } catch (error) { + this.cacheService.recordError( + 'users search parse', + namespacedKey, + error, + ); + } + } + const where: Prisma.UserWhereInput = {}; if (username) { @@ -151,6 +185,24 @@ export class UsersService { }, }); + await this.cacheService.set( + namespacedKey, + JSON.stringify(users), + CACHE_TTL_SECONDS.USERS_SEARCH, + ); + await this.cacheTagsService.rememberKeyForTag( + CACHE_NAMESPACE.USERS_SEARCH, + USERS_SEARCH_GLOBAL_TAG, + cacheKey, + ); + if (excludeUserId) { + await this.cacheTagsService.rememberKeyForTag( + CACHE_NAMESPACE.USERS_SEARCH, + usersSearchUserTag(excludeUserId), + cacheKey, + ); + } + return users; } @@ -251,7 +303,7 @@ export class UsersService { const now = new Date(); const roles: string[] = []; - return this.prisma.user.create({ + const user = await this.prisma.user.create({ data: { email: normalizeEmail(createDto.email), name: createDto.name, @@ -262,6 +314,12 @@ export class UsersService { keycloakSub: null, } as unknown as Prisma.UserUncheckedCreateInput, }); + + this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, { + userId: user.id, + }); + + return user; } async updatePasswordHash(