chore: add mobile postman updates and feed timing logs
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<boolean>('feedCache.enabled', { infer: true }) ?? true;
|
||||
@@ -91,6 +95,12 @@ export class FeedService {
|
||||
if (cacheEnabled) {
|
||||
const cached = await this.cacheService.get<Record<string, unknown>>(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<boolean>('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<Record<string, unknown>>(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<string, unknown> = { 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<string, unknown>),
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم