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",
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم