redis pt 2: read cache wiring & event based invalidations
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
verify,
|
verify,
|
||||||
} from 'jsonwebtoken';
|
} from 'jsonwebtoken';
|
||||||
import { PrismaService } from '../database/prisma.service';
|
import { PrismaService } from '../database/prisma.service';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import type { SocialAuthProfile } from './types/social-auth-profile';
|
import type { SocialAuthProfile } from './types/social-auth-profile';
|
||||||
import type {
|
import type {
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
usernameFromEmail,
|
usernameFromEmail,
|
||||||
} from './auth.utils';
|
} from './auth.utils';
|
||||||
import type { SsoProvider } from './dto/sso-provider';
|
import type { SsoProvider } from './dto/sso-provider';
|
||||||
|
import { UserEvents } from '../users/events/user.events';
|
||||||
|
|
||||||
interface SsoStateClaims {
|
interface SsoStateClaims {
|
||||||
provider: SsoProvider;
|
provider: SsoProvider;
|
||||||
@@ -56,6 +58,7 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {
|
) {
|
||||||
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
|
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
|
||||||
this.jwtIssuer =
|
this.jwtIssuer =
|
||||||
@@ -254,6 +257,10 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
return user;
|
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({
|
let user = await tx.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
@@ -311,6 +318,12 @@ export class AuthService {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveUsername(
|
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 { Global, Module } from '@nestjs/common';
|
||||||
import { RedisModule } from '../../database/redis.module';
|
import { RedisModule } from '../../database/redis.module';
|
||||||
|
import { CacheTagsService } from './cache-tags.service';
|
||||||
import { CacheService } from './cache.service';
|
import { CacheService } from './cache.service';
|
||||||
import { RedisThrottlerStorage } from './redis-throttler.storage';
|
import { RedisThrottlerStorage } from './redis-throttler.storage';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RedisModule],
|
imports: [RedisModule],
|
||||||
providers: [CacheService, RedisThrottlerStorage],
|
providers: [CacheService, CacheTagsService, RedisThrottlerStorage],
|
||||||
exports: [CacheService, RedisThrottlerStorage],
|
exports: [CacheService, CacheTagsService, RedisThrottlerStorage],
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
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 { Module, forwardRef } from '@nestjs/common';
|
||||||
import { DollsService } from './dolls.service';
|
import { DollsService } from './dolls.service';
|
||||||
import { DollsController } from './dolls.controller';
|
import { DollsController } from './dolls.controller';
|
||||||
|
import { DollsCacheInvalidationService } from './dolls-cache-invalidation.service';
|
||||||
import { DollsNotificationService } from './dolls-notification.service';
|
import { DollsNotificationService } from './dolls-notification.service';
|
||||||
import { DatabaseModule } from '../database/database.module';
|
import { DatabaseModule } from '../database/database.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
@@ -9,7 +10,11 @@ import { WsModule } from '../ws/ws.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, AuthModule, forwardRef(() => WsModule)],
|
imports: [DatabaseModule, AuthModule, forwardRef(() => WsModule)],
|
||||||
controllers: [DollsController],
|
controllers: [DollsController],
|
||||||
providers: [DollsService, DollsNotificationService],
|
providers: [
|
||||||
|
DollsService,
|
||||||
|
DollsNotificationService,
|
||||||
|
DollsCacheInvalidationService,
|
||||||
|
],
|
||||||
exports: [DollsService],
|
exports: [DollsService],
|
||||||
})
|
})
|
||||||
export class DollsModule {}
|
export class DollsModule {}
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ import {
|
|||||||
DollUpdatedEvent,
|
DollUpdatedEvent,
|
||||||
DollDeletedEvent,
|
DollDeletedEvent,
|
||||||
} from './events/doll.events';
|
} 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()
|
@Injectable()
|
||||||
export class DollsService {
|
export class DollsService {
|
||||||
@@ -23,6 +32,8 @@ export class DollsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly cacheTagsService: CacheTagsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getFriendIds(userId: string): Promise<string[]> {
|
async getFriendIds(userId: string): Promise<string[]> {
|
||||||
@@ -76,6 +87,48 @@ export class DollsService {
|
|||||||
async listByOwner(
|
async listByOwner(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
requestingUserId: 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[]> {
|
): Promise<Doll[]> {
|
||||||
// If requesting own dolls, no need to check friendship
|
// If requesting own dolls, no need to check friendship
|
||||||
if (ownerId === requestingUserId) {
|
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 { Module, forwardRef } from '@nestjs/common';
|
||||||
import { FriendsController } from './friends.controller';
|
import { FriendsController } from './friends.controller';
|
||||||
|
import { FriendsCacheInvalidationService } from './friends-cache-invalidation.service';
|
||||||
import { FriendsService } from './friends.service';
|
import { FriendsService } from './friends.service';
|
||||||
import { FriendsNotificationService } from './friends-notification.service';
|
import { FriendsNotificationService } from './friends-notification.service';
|
||||||
import { DatabaseModule } from '../database/database.module';
|
import { DatabaseModule } from '../database/database.module';
|
||||||
@@ -15,7 +16,11 @@ import { WsModule } from '../ws/ws.module';
|
|||||||
forwardRef(() => WsModule),
|
forwardRef(() => WsModule),
|
||||||
],
|
],
|
||||||
controllers: [FriendsController],
|
controllers: [FriendsController],
|
||||||
providers: [FriendsService, FriendsNotificationService],
|
providers: [
|
||||||
|
FriendsService,
|
||||||
|
FriendsNotificationService,
|
||||||
|
FriendsCacheInvalidationService,
|
||||||
|
],
|
||||||
exports: [FriendsService],
|
exports: [FriendsService],
|
||||||
})
|
})
|
||||||
export class FriendsModule {}
|
export class FriendsModule {}
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ import {
|
|||||||
FriendRequestDeniedEvent,
|
FriendRequestDeniedEvent,
|
||||||
UnfriendedEvent,
|
UnfriendedEvent,
|
||||||
} from './events/friend.events';
|
} 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 & {
|
export type FriendRequestWithRelations = FriendRequest & {
|
||||||
sender: User;
|
sender: User;
|
||||||
@@ -28,6 +37,8 @@ export class FriendsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly cacheTagsService: CacheTagsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async sendFriendRequest(
|
async sendFriendRequest(
|
||||||
@@ -272,7 +283,28 @@ export class FriendsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getFriends(userId: string) {
|
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 },
|
where: { userId },
|
||||||
include: {
|
include: {
|
||||||
friend: {
|
friend: {
|
||||||
@@ -285,6 +317,29 @@ export class FriendsService {
|
|||||||
createdAt: 'desc',
|
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> {
|
async unfriend(userId: string, friendId: string): Promise<void> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Doll } from '@prisma/client';
|
|||||||
|
|
||||||
export const UserEvents = {
|
export const UserEvents = {
|
||||||
ACTIVE_DOLL_CHANGED: 'user.active-doll.changed',
|
ACTIVE_DOLL_CHANGED: 'user.active-doll.changed',
|
||||||
|
SEARCH_INDEX_INVALIDATED: 'user.search-index.invalidated',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface UserActiveDollChangedEvent {
|
export interface UserActiveDollChangedEvent {
|
||||||
@@ -9,3 +10,7 @@ export interface UserActiveDollChangedEvent {
|
|||||||
dollId: string | null;
|
dollId: string | null;
|
||||||
doll: Doll | 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 { Module, forwardRef } from '@nestjs/common';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
import { UsersCacheInvalidationService } from './users-cache-invalidation.service';
|
||||||
import { UsersController } from './users.controller';
|
import { UsersController } from './users.controller';
|
||||||
import { UsersNotificationService } from './users-notification.service';
|
import { UsersNotificationService } from './users-notification.service';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
@@ -16,7 +17,11 @@ import { WsModule } from '../ws/ws.module';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => AuthModule), forwardRef(() => WsModule)],
|
imports: [forwardRef(() => AuthModule), forwardRef(() => WsModule)],
|
||||||
providers: [UsersService, UsersNotificationService],
|
providers: [
|
||||||
|
UsersService,
|
||||||
|
UsersNotificationService,
|
||||||
|
UsersCacheInvalidationService,
|
||||||
|
],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ import { User, Prisma } from '@prisma/client';
|
|||||||
import type { UpdateUserDto } from './dto/update-user.dto';
|
import type { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { UserEvents } from './events/user.events';
|
import { UserEvents } from './events/user.events';
|
||||||
import { normalizeEmail } from '../auth/auth.utils';
|
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 {
|
export interface CreateLocalUserDto {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -30,6 +39,8 @@ export class UsersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly cacheTagsService: CacheTagsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Legacy Keycloak user creation removed in favor of local auth.
|
// Legacy Keycloak user creation removed in favor of local auth.
|
||||||
@@ -92,6 +103,8 @@ export class UsersService {
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, { userId: id });
|
||||||
|
|
||||||
this.logger.log(`User ${id} profile update requested`);
|
this.logger.log(`User ${id} profile update requested`);
|
||||||
|
|
||||||
return updatedUser;
|
return updatedUser;
|
||||||
@@ -121,6 +134,8 @@ export class UsersService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, { userId: id });
|
||||||
|
|
||||||
this.logger.log(`User ${id} deleted their account`);
|
this.logger.log(`User ${id} deleted their account`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +143,25 @@ export class UsersService {
|
|||||||
username?: string,
|
username?: string,
|
||||||
excludeUserId?: string,
|
excludeUserId?: string,
|
||||||
): Promise<User[]> {
|
): 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 = {};
|
const where: Prisma.UserWhereInput = {};
|
||||||
|
|
||||||
if (username) {
|
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;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +303,7 @@ export class UsersService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const roles: string[] = [];
|
const roles: string[] = [];
|
||||||
|
|
||||||
return this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: normalizeEmail(createDto.email),
|
email: normalizeEmail(createDto.email),
|
||||||
name: createDto.name,
|
name: createDto.name,
|
||||||
@@ -262,6 +314,12 @@ export class UsersService {
|
|||||||
keycloakSub: null,
|
keycloakSub: null,
|
||||||
} as unknown as Prisma.UserUncheckedCreateInput,
|
} as unknown as Prisma.UserUncheckedCreateInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePasswordHash(
|
async updatePasswordHash(
|
||||||
|
|||||||
Reference in New Issue
Block a user