redis pt 2: read cache wiring & event based invalidations
This commit is contained in:
@@ -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<string>('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(
|
||||
|
||||
61
src/common/cache/cache-keys.ts
vendored
Normal file
61
src/common/cache/cache-keys.ts
vendored
Normal file
@@ -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)}`;
|
||||
}
|
||||
66
src/common/cache/cache-tags.service.ts
vendored
Normal file
66
src/common/cache/cache-tags.service.ts
vendored
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
5
src/common/cache/cache.module.ts
vendored
5
src/common/cache/cache.module.ts
vendored
@@ -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 {}
|
||||
|
||||
37
src/dolls/dolls-cache-invalidation.service.ts
Normal file
37
src/dolls/dolls-cache-invalidation.service.ts
Normal file
@@ -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<void> {
|
||||
await this.invalidateOwnerLists(payload.userId);
|
||||
}
|
||||
|
||||
@OnEvent(DollEvents.DOLL_UPDATED)
|
||||
async handleDollUpdated(payload: DollUpdatedEvent): Promise<void> {
|
||||
await this.invalidateOwnerLists(payload.userId);
|
||||
}
|
||||
|
||||
@OnEvent(DollEvents.DOLL_DELETED)
|
||||
async handleDollDeleted(payload: DollDeletedEvent): Promise<void> {
|
||||
await this.invalidateOwnerLists(payload.userId);
|
||||
}
|
||||
|
||||
private async invalidateOwnerLists(ownerId: string): Promise<void> {
|
||||
await this.cacheTagsService.invalidateTag(
|
||||
CACHE_NAMESPACE.DOLLS_LIST,
|
||||
dollsListOwnerTag(ownerId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<string[]> {
|
||||
@@ -76,6 +87,48 @@ export class DollsService {
|
||||
async listByOwner(
|
||||
ownerId: string,
|
||||
requestingUserId: string,
|
||||
): Promise<Doll[]> {
|
||||
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<Doll[]> {
|
||||
// If requesting own dolls, no need to check friendship
|
||||
if (ownerId === requestingUserId) {
|
||||
|
||||
65
src/friends/friends-cache-invalidation.service.ts
Normal file
65
src/friends/friends-cache-invalidation.service.ts
Normal file
@@ -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<void> {
|
||||
const senderId = payload.friendRequest.senderId;
|
||||
const receiverId = payload.friendRequest.receiverId;
|
||||
await this.invalidateFriendAndDollViews(senderId, receiverId);
|
||||
}
|
||||
|
||||
@OnEvent(FriendEvents.UNFRIENDED)
|
||||
async handleUnfriended(payload: UnfriendedEvent): Promise<void> {
|
||||
await this.invalidateFriendAndDollViews(payload.userId, payload.friendId);
|
||||
}
|
||||
|
||||
private async invalidateFriendAndDollViews(
|
||||
firstUserId: string,
|
||||
secondUserId: string,
|
||||
): Promise<void> {
|
||||
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),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<PrismaService['friendship']['findMany']>
|
||||
>;
|
||||
} 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<void> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
38
src/users/users-cache-invalidation.service.ts
Normal file
38
src/users/users-cache-invalidation.service.ts
Normal file
@@ -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<void> {
|
||||
const tasks: Promise<void>[] = [
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<User[]> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user