diff --git a/postman/Oudelaa-Mobile.postman_collection.json b/postman/Oudelaa-Mobile.postman_collection.json index 522d8af..d6a5ea7 100644 --- a/postman/Oudelaa-Mobile.postman_collection.json +++ b/postman/Oudelaa-Mobile.postman_collection.json @@ -5403,6 +5403,28 @@ } } ] + }, + { + "name": "Public Get Shop By Admin Id", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/marketplace/shops/{{adminUserId}}", + "description": "Fetch the public marketplace shop profile for a specific admin/store owner." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json).to.be.an('object');" + ] + } + } + ] } ] } @@ -5827,10 +5849,16 @@ "name": "Register Device FCM Token", "request": { "method": "POST", - "header": { - "key": "Content-Type", - "value": "application/json" - }, + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{baseUrl}}/devices/register", "body": { "mode": "raw", @@ -5851,10 +5879,16 @@ "name": "Unregister Device FCM Token", "request": { "method": "POST", - "header": { - "key": "Content-Type", - "value": "application/json" - }, + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{baseUrl}}/devices/unregister", "body": { "mode": "raw", @@ -6401,6 +6435,21 @@ { "key": "collaborationAttachmentType", "value": "audio" + }, + { + "key": "collaborationRequestId", + "value": "", + "type": "string" + }, + { + "key": "fcmToken", + "value": "postman-fcm-token", + "type": "string" + }, + { + "key": "deviceId", + "value": "postman-device-android", + "type": "string" } ] } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index f753b9e..1b19220 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -135,6 +135,10 @@ export default () => ({ userFeedTtlSeconds: Number(process.env.FEED_CACHE_USER_TTL_SECONDS ?? 15), trendingTtlSeconds: Number(process.env.FEED_CACHE_TRENDING_TTL_SECONDS ?? 30), }, + performance: { + feedTimingLogsEnabled: + (process.env.FEED_TIMING_LOGS_ENABLED ?? 'false').toLowerCase() === 'true', + }, passwordReset: { codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10), maxAttempts: Number(process.env.PASSWORD_RESET_MAX_ATTEMPTS ?? 5), diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index f31fd56..6e9d765 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -88,6 +88,7 @@ export const validationSchema = Joi.object({ FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true), FEED_CACHE_USER_TTL_SECONDS: Joi.number().min(1).max(3600).default(15), FEED_CACHE_TRENDING_TTL_SECONDS: Joi.number().min(1).max(3600).default(30), + FEED_TIMING_LOGS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false), PASSWORD_RESET_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10), PASSWORD_RESET_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5), PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(), diff --git a/src/modules/feed/feed.service.ts b/src/modules/feed/feed.service.ts index 6a2bbd1..e7a2111 100644 --- a/src/modules/feed/feed.service.ts +++ b/src/modules/feed/feed.service.ts @@ -1,6 +1,7 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Types } from 'mongoose'; +import { performance } from 'perf_hooks'; import { PostType } from '../../common/enums/post-type.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util'; @@ -57,6 +58,8 @@ type FeedCardItem = @Injectable() export class FeedService { + private readonly logger = new Logger(FeedService.name); + constructor( private readonly feedRepository: FeedRepository, private readonly usersRepository: UsersRepository, @@ -71,6 +74,7 @@ export class FeedService { ) {} async getMyFeed(currentUserId: string, query: FeedQueryDto) { + const timing = this.startTiming('feed.me'); const followingOnly = query.followingOnly ?? true; const cacheEnabled = this.configService.get('feedCache.enabled', { infer: true }) ?? true; @@ -91,6 +95,12 @@ export class FeedService { if (cacheEnabled) { const cached = await this.cacheService.get>(cacheKey); if (cached) { + this.logFeedTiming(timing, { + cacheHit: true, + itemCount: Array.isArray(cached.items) ? cached.items.length : undefined, + nextCursor: cached.nextCursor ?? null, + responseBytes: this.measureResponseBytes(cached), + }); return cached; } } @@ -110,8 +120,10 @@ export class FeedService { this.feedRepository.findFollowingIds(currentUserId), this.blocksService.getInvisibleUserIds(currentUserId), ]); + const relationLookupMs = this.markTiming(timing); let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds); let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); + const firstCandidateLookupMs = this.markTiming(timing); // Keep the default home feed focused on followed accounts, but avoid an empty screen // for new users or when followed accounts have not posted yet. @@ -123,6 +135,7 @@ export class FeedService { filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, invisibleUserIds); candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); } + const fallbackCandidateLookupMs = this.markTiming(timing); const scored = candidates .filter((post) => { @@ -148,6 +161,7 @@ export class FeedService { new Date((b.post as any).createdAt ?? 0).getTime() - new Date((a.post as any).createdAt ?? 0).getTime(), ); + const scoringMs = this.markTiming(timing); const total = scored.length; const pagedPosts = scored.slice(skip, skip + limit).map((entry) => ({ @@ -155,9 +169,11 @@ export class FeedService { feedScore: Number(entry.score.toFixed(3)), })); const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds); + const decorationMs = this.markTiming(timing); const items = includeSuggestions ? await this.mixHomeFeedItems(currentUserId, decoratedPosts, query.suggestionInterval ?? 4) : decoratedPosts; + const cardsMs = this.markTiming(timing); const nextOffset = skip + pagedPosts.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; @@ -179,6 +195,29 @@ export class FeedService { ); } + this.logFeedTiming(timing, { + cacheHit: false, + page, + limit, + skip, + followingOnly, + includeSuggestions, + followingCount: followingIds.length, + invisibleUserCount: invisibleUserIds.length, + candidateCount: candidates.length, + scoredCount: scored.length, + itemCount: items.length, + postItemCount: decoratedPosts.length, + nextCursor, + relationLookupMs, + firstCandidateLookupMs, + fallbackCandidateLookupMs, + scoringMs, + decorationMs, + cardsMs, + responseBytes: this.measureResponseBytes(result), + }); + return result; } @@ -191,6 +230,7 @@ export class FeedService { } async getTrending(currentUserId: string, query: FeedQueryDto) { + const timing = this.startTiming('feed.trending'); const cacheEnabled = this.configService.get('feedCache.enabled', { infer: true }) ?? true; const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0; @@ -205,6 +245,12 @@ export class FeedService { if (cacheEnabled) { const cached = await this.cacheService.get>(cacheKey); if (cached) { + this.logFeedTiming(timing, { + cacheHit: true, + itemCount: Array.isArray(cached.items) ? cached.items.length : undefined, + nextCursor: cached.nextCursor ?? null, + responseBytes: this.measureResponseBytes(cached), + }); return cached; } } @@ -217,6 +263,7 @@ export class FeedService { this.feedRepository.findFollowingIds(currentUserId), this.blocksService.getInvisibleUserIds(currentUserId), ]); + const relationLookupMs = this.markTiming(timing); const trendingFilter: Record = { visibility: PostVisibility.PUBLIC }; if (invisibleUserIds.length) { trendingFilter.authorId = { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) }; @@ -229,11 +276,13 @@ export class FeedService { this.feedRepository.findTrendingPublicPosts(trendingFilter, skip, limit), this.feedRepository.count(trendingFilter), ]); + const postLookupMs = this.markTiming(timing); const decoratedPosts = await this.decoratePostsForViewer( currentUserId, rows.map((item) => item.toObject() as unknown as Record), followingIds, ); + const decorationMs = this.markTiming(timing); const nextOffset = skip + rows.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; @@ -255,6 +304,23 @@ export class FeedService { ); } + this.logFeedTiming(timing, { + cacheHit: false, + page, + limit, + skip, + preferredPostType: query.preferredPostType ?? '', + followingCount: followingIds.length, + invisibleUserCount: invisibleUserIds.length, + itemCount: decoratedPosts.length, + total, + nextCursor, + relationLookupMs, + postLookupMs, + decorationMs, + responseBytes: this.measureResponseBytes(result), + }); + return result; } @@ -581,4 +647,40 @@ export class FeedService { return ''; } + + private startTiming(scope: string): { scope: string; startedAt: number; lastAt: number } { + const now = performance.now(); + return { scope, startedAt: now, lastAt: now }; + } + + private markTiming(timing: { lastAt: number }): number { + const now = performance.now(); + const elapsed = now - timing.lastAt; + timing.lastAt = now; + return Math.round(elapsed); + } + + private logFeedTiming( + timing: { scope: string; startedAt: number }, + details: Record, + ): void { + if (!this.configService.get('performance.feedTimingLogsEnabled', { infer: true })) { + return; + } + + this.logger.log({ + event: 'feed_timing', + scope: timing.scope, + totalMs: Math.round(performance.now() - timing.startedAt), + ...details, + }); + } + + private measureResponseBytes(value: unknown): number { + try { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); + } catch { + return 0; + } + } }