chore: add mobile postman updates and feed timing logs
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-06-07 00:16:43 +03:00
الأصل f149b77139
التزام db8628dc68
4 ملفات معدلة مع 165 إضافات و9 حذوفات

عرض الملف

@@ -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", "name": "Register Device FCM Token",
"request": { "request": {
"method": "POST", "method": "POST",
"header": { "header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
},
{
"key": "Content-Type", "key": "Content-Type",
"value": "application/json" "value": "application/json"
}, }
],
"url": "{{baseUrl}}/devices/register", "url": "{{baseUrl}}/devices/register",
"body": { "body": {
"mode": "raw", "mode": "raw",
@@ -5851,10 +5879,16 @@
"name": "Unregister Device FCM Token", "name": "Unregister Device FCM Token",
"request": { "request": {
"method": "POST", "method": "POST",
"header": { "header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
},
{
"key": "Content-Type", "key": "Content-Type",
"value": "application/json" "value": "application/json"
}, }
],
"url": "{{baseUrl}}/devices/unregister", "url": "{{baseUrl}}/devices/unregister",
"body": { "body": {
"mode": "raw", "mode": "raw",
@@ -6401,6 +6435,21 @@
{ {
"key": "collaborationAttachmentType", "key": "collaborationAttachmentType",
"value": "audio" "value": "audio"
},
{
"key": "collaborationRequestId",
"value": "",
"type": "string"
},
{
"key": "fcmToken",
"value": "postman-fcm-token",
"type": "string"
},
{
"key": "deviceId",
"value": "postman-device-android",
"type": "string"
} }
] ]
} }

عرض الملف

@@ -135,6 +135,10 @@ export default () => ({
userFeedTtlSeconds: Number(process.env.FEED_CACHE_USER_TTL_SECONDS ?? 15), userFeedTtlSeconds: Number(process.env.FEED_CACHE_USER_TTL_SECONDS ?? 15),
trendingTtlSeconds: Number(process.env.FEED_CACHE_TRENDING_TTL_SECONDS ?? 30), trendingTtlSeconds: Number(process.env.FEED_CACHE_TRENDING_TTL_SECONDS ?? 30),
}, },
performance: {
feedTimingLogsEnabled:
(process.env.FEED_TIMING_LOGS_ENABLED ?? 'false').toLowerCase() === 'true',
},
passwordReset: { passwordReset: {
codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10), codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10),
maxAttempts: Number(process.env.PASSWORD_RESET_MAX_ATTEMPTS ?? 5), maxAttempts: Number(process.env.PASSWORD_RESET_MAX_ATTEMPTS ?? 5),

عرض الملف

@@ -88,6 +88,7 @@ export const validationSchema = Joi.object({
FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true), 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_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_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_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_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(), PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(),

عرض الملف

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { performance } from 'perf_hooks';
import { PostType } from '../../common/enums/post-type.enum'; import { PostType } from '../../common/enums/post-type.enum';
import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum';
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util'; import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
@@ -57,6 +58,8 @@ type FeedCardItem =
@Injectable() @Injectable()
export class FeedService { export class FeedService {
private readonly logger = new Logger(FeedService.name);
constructor( constructor(
private readonly feedRepository: FeedRepository, private readonly feedRepository: FeedRepository,
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
@@ -71,6 +74,7 @@ export class FeedService {
) {} ) {}
async getMyFeed(currentUserId: string, query: FeedQueryDto) { async getMyFeed(currentUserId: string, query: FeedQueryDto) {
const timing = this.startTiming('feed.me');
const followingOnly = query.followingOnly ?? true; const followingOnly = query.followingOnly ?? true;
const cacheEnabled = const cacheEnabled =
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true; this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
@@ -91,6 +95,12 @@ export class FeedService {
if (cacheEnabled) { if (cacheEnabled) {
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey); const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
if (cached) { 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; return cached;
} }
} }
@@ -110,8 +120,10 @@ export class FeedService {
this.feedRepository.findFollowingIds(currentUserId), this.feedRepository.findFollowingIds(currentUserId),
this.blocksService.getInvisibleUserIds(currentUserId), this.blocksService.getInvisibleUserIds(currentUserId),
]); ]);
const relationLookupMs = this.markTiming(timing);
let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds); let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds);
let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); 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 // 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. // 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); filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, invisibleUserIds);
candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
} }
const fallbackCandidateLookupMs = this.markTiming(timing);
const scored = candidates const scored = candidates
.filter((post) => { .filter((post) => {
@@ -148,6 +161,7 @@ export class FeedService {
new Date((b.post as any).createdAt ?? 0).getTime() - new Date((b.post as any).createdAt ?? 0).getTime() -
new Date((a.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 total = scored.length;
const pagedPosts = scored.slice(skip, skip + limit).map((entry) => ({ const pagedPosts = scored.slice(skip, skip + limit).map((entry) => ({
@@ -155,9 +169,11 @@ export class FeedService {
feedScore: Number(entry.score.toFixed(3)), feedScore: Number(entry.score.toFixed(3)),
})); }));
const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds); const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds);
const decorationMs = this.markTiming(timing);
const items = includeSuggestions const items = includeSuggestions
? await this.mixHomeFeedItems(currentUserId, decoratedPosts, query.suggestionInterval ?? 4) ? await this.mixHomeFeedItems(currentUserId, decoratedPosts, query.suggestionInterval ?? 4)
: decoratedPosts; : decoratedPosts;
const cardsMs = this.markTiming(timing);
const nextOffset = skip + pagedPosts.length; const nextOffset = skip + pagedPosts.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; 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; return result;
} }
@@ -191,6 +230,7 @@ export class FeedService {
} }
async getTrending(currentUserId: string, query: FeedQueryDto) { async getTrending(currentUserId: string, query: FeedQueryDto) {
const timing = this.startTiming('feed.trending');
const cacheEnabled = const cacheEnabled =
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true; this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0; const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
@@ -205,6 +245,12 @@ export class FeedService {
if (cacheEnabled) { if (cacheEnabled) {
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey); const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
if (cached) { 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; return cached;
} }
} }
@@ -217,6 +263,7 @@ export class FeedService {
this.feedRepository.findFollowingIds(currentUserId), this.feedRepository.findFollowingIds(currentUserId),
this.blocksService.getInvisibleUserIds(currentUserId), this.blocksService.getInvisibleUserIds(currentUserId),
]); ]);
const relationLookupMs = this.markTiming(timing);
const trendingFilter: Record<string, unknown> = { visibility: PostVisibility.PUBLIC }; const trendingFilter: Record<string, unknown> = { visibility: PostVisibility.PUBLIC };
if (invisibleUserIds.length) { if (invisibleUserIds.length) {
trendingFilter.authorId = { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) }; 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.findTrendingPublicPosts(trendingFilter, skip, limit),
this.feedRepository.count(trendingFilter), this.feedRepository.count(trendingFilter),
]); ]);
const postLookupMs = this.markTiming(timing);
const decoratedPosts = await this.decoratePostsForViewer( const decoratedPosts = await this.decoratePostsForViewer(
currentUserId, currentUserId,
rows.map((item) => item.toObject() as unknown as Record<string, unknown>), rows.map((item) => item.toObject() as unknown as Record<string, unknown>),
followingIds, followingIds,
); );
const decorationMs = this.markTiming(timing);
const nextOffset = skip + rows.length; const nextOffset = skip + rows.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; 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; return result;
} }
@@ -581,4 +647,40 @@ export class FeedService {
return ''; 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<string, unknown>,
): void {
if (!this.configService.get<boolean>('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;
}
}
} }