This repository has no description
7.3 kB
231 lines
1import Redis from 'ioredis';
2import {
3 IProfileService,
4 UserProfile,
5} from 'src/modules/cards/domain/services/IProfileService';
6import { Result, ok } from 'src/shared/core/Result';
7import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository';
8import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType';
9
10export class CachedBlueskyProfileService implements IProfileService {
11 private readonly CACHE_TTL_SECONDS = 3600 * 12; // 12 hours
12 private readonly CACHE_KEY_PREFIX = 'profile:';
13 private readonly COUNTS_CACHE_TTL_SECONDS = 900; // 15 minutes
14 private readonly COUNTS_CACHE_KEY_PREFIX = 'profile:counts:';
15
16 constructor(
17 private readonly profileService: IProfileService,
18 private readonly redis: Redis,
19 private readonly followsRepository: IFollowsRepository,
20 ) {}
21
22 async getProfile(
23 userId: string,
24 callerId?: string,
25 ): Promise<Result<UserProfile>> {
26 const profileCacheKey = this.getCacheKey(userId);
27 const countsCacheKey = this.getCountsCacheKey(userId);
28
29 try {
30 // Fetch both profile and counts from cache in parallel
31 const [cachedProfile, cachedCounts] = await Promise.all([
32 this.redis.get(profileCacheKey),
33 this.redis.get(countsCacheKey),
34 ]);
35
36 let profile: UserProfile;
37 let counts: {
38 followerCount: number;
39 followingCount: number;
40 followedCollectionsCount: number;
41 };
42
43 // Handle profile cache
44 if (cachedProfile) {
45 try {
46 profile = JSON.parse(cachedProfile) as UserProfile;
47 } catch (parseError) {
48 // If JSON parsing fails, continue to fetch fresh data
49 console.warn(
50 `Failed to parse cached profile for ${userId}:`,
51 parseError,
52 );
53 // Fall through to fetch from service
54 const result = await this.profileService.getProfile(userId, callerId);
55 if (result.isErr()) {
56 return result;
57 }
58 profile = result.value;
59 }
60 } else {
61 // Cache miss - fetch from underlying service
62 const result = await this.profileService.getProfile(userId, callerId);
63
64 if (result.isErr()) {
65 return result;
66 }
67
68 profile = result.value;
69
70 // Cache the profile (without follow status and counts)
71 try {
72 const profileToCache = { ...profile };
73 delete profileToCache.isFollowing;
74 delete profileToCache.followerCount;
75 delete profileToCache.followingCount;
76 delete profileToCache.followedCollectionsCount;
77 await this.redis.setex(
78 profileCacheKey,
79 this.CACHE_TTL_SECONDS,
80 JSON.stringify(profileToCache),
81 );
82 } catch (cacheError) {
83 // Log cache error but don't fail the request
84 console.warn(`Failed to cache profile for ${userId}:`, cacheError);
85 }
86 }
87
88 // Handle counts cache
89 if (cachedCounts) {
90 try {
91 counts = JSON.parse(cachedCounts) as {
92 followerCount: number;
93 followingCount: number;
94 followedCollectionsCount: number;
95 };
96 } catch (parseError) {
97 console.warn(
98 `Failed to parse cached counts for ${userId}:`,
99 parseError,
100 );
101 // Use counts from profile or defaults
102 counts = {
103 followerCount: profile.followerCount ?? 0,
104 followingCount: profile.followingCount ?? 0,
105 followedCollectionsCount: profile.followedCollectionsCount ?? 0,
106 };
107 }
108 } else {
109 // Counts cache miss - use from profile or fetch fresh
110 if (
111 profile.followerCount !== undefined &&
112 profile.followingCount !== undefined &&
113 profile.followedCollectionsCount !== undefined
114 ) {
115 counts = {
116 followerCount: profile.followerCount,
117 followingCount: profile.followingCount,
118 followedCollectionsCount: profile.followedCollectionsCount,
119 };
120 } else {
121 // If profile doesn't have counts, fetch fresh
122 const result = await this.profileService.getProfile(userId, callerId);
123 if (result.isOk()) {
124 counts = {
125 followerCount: result.value.followerCount ?? 0,
126 followingCount: result.value.followingCount ?? 0,
127 followedCollectionsCount:
128 result.value.followedCollectionsCount ?? 0,
129 };
130 } else {
131 counts = {
132 followerCount: 0,
133 followingCount: 0,
134 followedCollectionsCount: 0,
135 };
136 }
137 }
138
139 // Cache the counts
140 try {
141 await this.redis.setex(
142 countsCacheKey,
143 this.COUNTS_CACHE_TTL_SECONDS,
144 JSON.stringify(counts),
145 );
146 } catch (cacheError) {
147 console.warn(`Failed to cache counts for ${userId}:`, cacheError);
148 }
149 }
150
151 // Add follow status if callerId is provided
152 let isFollowing: boolean | undefined = undefined;
153 let followsYou: boolean | undefined = undefined;
154 if (callerId && callerId !== userId) {
155 const followResult =
156 await this.followsRepository.findByFollowerAndTarget(
157 callerId,
158 userId,
159 FollowTargetType.USER,
160 );
161
162 if (followResult.isOk()) {
163 isFollowing = followResult.value !== null;
164 }
165
166 // Check if the profile user follows the caller
167 const followsYouResult =
168 await this.followsRepository.findByFollowerAndTarget(
169 userId,
170 callerId,
171 FollowTargetType.USER,
172 );
173
174 if (followsYouResult.isOk()) {
175 followsYou = followsYouResult.value !== null;
176 }
177 }
178
179 return ok({
180 ...profile,
181 isFollowing,
182 followsYou,
183 ...counts,
184 });
185 } catch (redisError) {
186 // If Redis is down, fall back to direct service call
187 console.warn(
188 `Redis error when fetching profile for ${userId}:`,
189 redisError,
190 );
191 return this.profileService.getProfile(userId, callerId);
192 }
193 }
194
195 private getCacheKey(userId: string): string {
196 return `${this.CACHE_KEY_PREFIX}${userId}`;
197 }
198
199 private getCountsCacheKey(userId: string): string {
200 return `${this.COUNTS_CACHE_KEY_PREFIX}${userId}`;
201 }
202
203 /**
204 * Invalidate cached profile for a specific user
205 */
206 async invalidateProfile(userId: string): Promise<void> {
207 try {
208 await this.redis.del(this.getCacheKey(userId));
209 } catch (error) {
210 console.warn(`Failed to invalidate profile cache for ${userId}:`, error);
211 }
212 }
213
214 /**
215 * Invalidate cached counts for a specific user
216 */
217 async invalidateCounts(userId: string): Promise<void> {
218 try {
219 await this.redis.del(this.getCountsCacheKey(userId));
220 } catch (error) {
221 console.warn(`Failed to invalidate counts cache for ${userId}:`, error);
222 }
223 }
224
225 /**
226 * Warm the cache by pre-fetching a profile
227 */
228 async warmCache(userId: string, callerId?: string): Promise<void> {
229 await this.getProfile(userId, callerId);
230 }
231}