feat(database-schema): добавляет ссылку на активный чат в схему профиля пользователя

 feat(likes.service): обновляет логику создания лайков с учетом активного чата

 feat(likes.module): импортирует модуль Gateways для работы с чатами

 feat(feed.service): добавляет условие для фильтрации профилей без активного чата

 feat(storage.service): устанавливает политику доступа к бакету S3 для получения объектов

 feat(likes-response.dto): добавляет поле чата в ответ на лайк

 feat(media.controller): добавляет описание для загрузки медиафайлов

 feat(chat.service): добавляет возможность закрытия чата с отчетом или сообщением

 feat(chat.controller): обновляет метод закрытия чата для обработки отчетов

 feat(app.module): добавляет модуль Dev для разработки в не продакшн среде
This commit is contained in:
Oscar
2026-06-09 15:39:38 +03:00
parent 97f8891861
commit cd98f04987
14 changed files with 251 additions and 36 deletions

View File

@@ -20,7 +20,6 @@
| role_id | uuid FK → role | роль пользователя | | role_id | uuid FK → role | роль пользователя |
| tariff_id | uuid FK → tariff | текущий тариф | | tariff_id | uuid FK → tariff | текущий тариф |
| payment_id | uuid FK → payment | способ оплаты | | payment_id | uuid FK → payment | способ оплаты |
| active_chat_id | uuid FK → chat | активный чат (один пользователь — один чат одновременно) |
| fcm_token | string | токен для push-уведомлений (FCM) | | fcm_token | string | токен для push-уведомлений (FCM) |
--- ---
@@ -40,6 +39,7 @@
| nation | string | национальность | | nation | string | национальность |
| height | float | опционально | | height | float | опционально |
| weight | float | опционально | | weight | float | опционально |
| active_chat_id | uuid | ссылка на активный чат (один профиль — один чат одновременно) |
--- ---

View File

@@ -29,6 +29,7 @@ import { TagsModule } from './modules/tags/tags.module';
import { CitiesModule } from './modules/cities/cities.module'; import { CitiesModule } from './modules/cities/cities.module';
import { GreetingsModule } from './modules/greetings/greetings.module'; import { GreetingsModule } from './modules/greetings/greetings.module';
import { GatewaysModule } from './gateways/gateways.module'; import { GatewaysModule } from './gateways/gateways.module';
import { DevModule } from './modules/dev/dev.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { AllExceptionsFilter } from './common/filters/http-exception.filter'; import { AllExceptionsFilter } from './common/filters/http-exception.filter';
@@ -57,6 +58,7 @@ import { TransformInterceptor } from './common/interceptors/transform.intercepto
CitiesModule, CitiesModule,
GreetingsModule, GreetingsModule,
GatewaysModule, GatewaysModule,
...(process.env.NODE_ENV !== 'production' ? [DevModule] : []),
], ],
providers: [ providers: [
{ {

View File

@@ -4,6 +4,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ChatService } from './chat.service'; import { ChatService } from './chat.service';
import { CreateChatDto } from './dto/create-chat.dto'; import { CreateChatDto } from './dto/create-chat.dto';
import { CloseChatDto } from './dto/close-chat.dto';
import { SendMessageDto } from './dto/send-message.dto'; import { SendMessageDto } from './dto/send-message.dto';
import { ChatDto, MessageDto } from './dto/chat-response.dto'; import { ChatDto, MessageDto } from './dto/chat-response.dto';
import { MessageResponseDto } from '../../common/dto/message-response.dto'; import { MessageResponseDto } from '../../common/dto/message-response.dto';
@@ -60,13 +61,14 @@ export class ChatController {
} }
@Delete(':chatId') @Delete(':chatId')
@ApiOperation({ summary: 'Close a chat' }) @ApiOperation({ summary: 'Close a chat (cancel match or report)' })
@ApiOkResponse({ type: MessageResponseDto }) @ApiOkResponse({ type: MessageResponseDto })
closeChat( closeChat(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,
@Query('profileId') profileId: string, @Query('profileId') profileId: string,
@Param('chatId') chatId: string, @Param('chatId') chatId: string,
@Body() dto: CloseChatDto,
) { ) {
return this.chatService.closeChat(userId, profileId, chatId); return this.chatService.closeChat(userId, profileId, chatId, dto);
} }
} }

View File

@@ -6,9 +6,10 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { and, eq, or } from 'drizzle-orm'; import { and, eq, or } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { chat, match, message, profile, user } from '../../database/schema'; import { chat, match, message, profile, report, user } from '../../database/schema';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { CreateChatDto } from './dto/create-chat.dto'; import { CreateChatDto } from './dto/create-chat.dto';
import { CloseChatDto, CloseChatType } from './dto/close-chat.dto';
import { SendMessageDto } from './dto/send-message.dto'; import { SendMessageDto } from './dto/send-message.dto';
@Injectable() @Injectable()
@@ -76,7 +77,7 @@ export class ChatService {
return newChat; return newChat;
} }
async closeChat(userId: string, profileId: string, chatId: string) { async closeChat(userId: string, profileId: string, chatId: string, dto: CloseChatDto) {
await this.assertProfileOwnership(userId, profileId); await this.assertProfileOwnership(userId, profileId);
const [foundChat] = await this.drizzleService.db const [foundChat] = await this.drizzleService.db
@@ -90,6 +91,39 @@ export class ChatService {
throw new ForbiddenException('Not a chat participant'); throw new ForbiddenException('Not a chat participant');
} }
const otherProfileId = foundChat.profile1Id === profileId
? foundChat.profile2Id
: foundChat.profile1Id;
let messageText: string;
if (dto.type === CloseChatType.Report) {
if (!dto.reportReason) throw new BadRequestException('reportReason is required for type=report');
messageText = dto.reportDescription
? `Жалоба: ${dto.reportReason}. ${dto.reportDescription}`
: `Жалоба: ${dto.reportReason}`;
await this.drizzleService.db
.insert(report)
.values({
sourceProfileId: profileId,
entityId: otherProfileId,
entityType: 'profile',
description: messageText,
} as any);
} else {
messageText = dto.cancelMessage?.trim() || 'Диалог завершён';
}
await this.drizzleService.db
.insert(message)
.values({
chatId,
profileId,
text: messageText,
mediaUrl: null,
mediaType: null,
} as any);
await this.drizzleService.db await this.drizzleService.db
.update(chat) .update(chat)
.set({ status: 'closed' } as any) .set({ status: 'closed' } as any)
@@ -108,12 +142,7 @@ export class ChatService {
return this.drizzleService.db return this.drizzleService.db
.select() .select()
.from(chat) .from(chat)
.where( .where(or(eq(chat.profile1Id, profileId), eq(chat.profile2Id, profileId)));
and(
or(eq(chat.profile1Id, profileId), eq(chat.profile2Id, profileId)),
eq(chat.status, 'active'),
),
);
} }
async getChatMessages(userId: string, profileId: string, chatId: string, page = 1, limit = 50) { async getChatMessages(userId: string, profileId: string, chatId: string, page = 1, limit = 50) {

View File

@@ -0,0 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
export enum CloseChatType {
Cancel = 'cancel',
Report = 'report',
}
export class CloseChatDto {
@ApiProperty({ enum: CloseChatType, description: 'cancel — отменить матч, report — пожаловаться' })
@IsEnum(CloseChatType)
type: CloseChatType;
@ApiPropertyOptional({ description: 'Сообщение собеседнику при отмене матча' })
@IsOptional()
@IsString()
@MaxLength(500)
cancelMessage?: string;
@ApiPropertyOptional({ description: 'Причина жалобы (preset)' })
@IsOptional()
@IsString()
@MaxLength(200)
reportReason?: string;
@ApiPropertyOptional({ description: 'Дополнительное описание жалобы' })
@IsOptional()
@IsString()
@MaxLength(500)
reportDescription?: string;
}

View File

@@ -0,0 +1,71 @@
import { Controller, Delete, Get, Query } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { eq, inArray, or } from 'drizzle-orm';
import { Public } from '../../common/decorators/public.decorator';
import { DrizzleService } from '../../database/drizzle.service';
import { chat, like, match, profile, user } from '../../database/schema';
@ApiTags('dev')
@Controller('dev')
export class DevController {
constructor(private readonly drizzle: DrizzleService) {}
@Public()
@Get('profiles')
@ApiOperation({ summary: '[DEV ONLY] List all profiles with basic info' })
async getAllProfiles() {
return this.drizzle.db
.select({
id: profile.id,
name: profile.name,
activeChatId: profile.activeChatId,
phone: user.phone,
})
.from(profile)
.leftJoin(user, eq(user.id, profile.userId))
.orderBy(profile.name);
}
@Public()
@Delete('reset-flow')
@ApiOperation({
summary: '[DEV ONLY] Reset like/match/chat flow for given profiles',
description: 'Clears likes, matches, chats and resets activeChatId. Never available in production.',
})
@ApiQuery({ name: 'profileIds', description: 'Comma-separated profile UUIDs' })
async resetFlow(@Query('profileIds') profileIds: string) {
const ids = profileIds.split(',').map(s => s.trim()).filter(Boolean);
if (!ids.length) return { deleted: { likes: 0, matches: 0, chats: 0 }, reset: 0 };
const deletedLikes = await this.drizzle.db
.delete(like)
.where(or(inArray(like.sourceProfileId, ids), inArray(like.targetProfileId, ids)))
.returning({ id: like.id });
const deletedMatches = await this.drizzle.db
.delete(match)
.where(or(inArray(match.profile1Id, ids), inArray(match.profile2Id, ids)))
.returning({ id: match.id });
// chats cascade-delete messages
const deletedChats = await this.drizzle.db
.delete(chat)
.where(or(inArray(chat.profile1Id, ids), inArray(chat.profile2Id, ids)))
.returning({ id: chat.id });
const updated = await this.drizzle.db
.update(profile)
.set({ activeChatId: null } as any)
.where(inArray(profile.id, ids))
.returning({ id: profile.id });
return {
deleted: {
likes: deletedLikes.length,
matches: deletedMatches.length,
chats: deletedChats.length,
},
reset: updated.length,
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { DevController } from './dev.controller';
@Module({
controllers: [DevController],
})
export class DevModule {}

View File

@@ -29,4 +29,4 @@ export class FeedController {
getFeed(@CurrentUser('id') userId: string, @Query() filter: FeedFilterDto) { getFeed(@CurrentUser('id') userId: string, @Query() filter: FeedFilterDto) {
return this.feedService.getFeed(userId, filter); return this.feedService.getFeed(userId, filter);
} }
} }

View File

@@ -1,5 +1,5 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { and, eq, gte, ilike, inArray, lte, ne, notInArray, or, sql } from 'drizzle-orm'; import { and, eq, gte, ilike, inArray, isNull, lte, ne, notInArray, or, sql } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { like, profile, profileMedia, profileTag, tag, user } from '../../database/schema'; import { like, profile, profileMedia, profileTag, tag, user } from '../../database/schema';
import { FeedFilterDto } from './dto/feed-filter.dto'; import { FeedFilterDto } from './dto/feed-filter.dto';
@@ -24,6 +24,7 @@ export class FeedService {
const conditions: any[] = [ const conditions: any[] = [
ne(profile.id, profileId), ne(profile.id, profileId),
ne(user.status, 'banned'), ne(user.status, 'banned'),
isNull(profile.activeChatId),
]; ];
if (interactedIds.length > 0) { if (interactedIds.length > 0) {

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ChatDto } from '../../chat/dto/chat-response.dto';
export class LikeDto { export class LikeDto {
@ApiProperty() id: string; @ApiProperty() id: string;
@@ -18,4 +19,5 @@ export class MatchDto {
export class CreateLikeResponseDto { export class CreateLikeResponseDto {
@ApiProperty({ type: LikeDto }) like: LikeDto; @ApiProperty({ type: LikeDto }) like: LikeDto;
@ApiPropertyOptional({ type: MatchDto, nullable: true }) match: MatchDto | null; @ApiPropertyOptional({ type: MatchDto, nullable: true }) match: MatchDto | null;
@ApiPropertyOptional({ type: ChatDto, nullable: true }) chat: ChatDto | null;
} }

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { LikesController } from './likes.controller'; import { LikesController } from './likes.controller';
import { LikesService } from './likes.service'; import { LikesService } from './likes.service';
import { GatewaysModule } from '../../gateways/gateways.module';
@Module({ @Module({
imports: [GatewaysModule],
controllers: [LikesController], controllers: [LikesController],
providers: [LikesService], providers: [LikesService],
}) })

View File

@@ -2,9 +2,10 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
import { and, eq, or } from 'drizzle-orm'; import { and, eq, or } from 'drizzle-orm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DrizzleService } from '../../database/drizzle.service'; import { DrizzleService } from '../../database/drizzle.service';
import { like, match, profile, user } from '../../database/schema'; import { chat, like, match, profile, user } from '../../database/schema';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { RedisService } from '../../redis/redis.service'; import { RedisService } from '../../redis/redis.service';
import { ChatGateway } from '../../gateways/chat.gateway';
import { CreateLikeDto } from './dto/create-like.dto'; import { CreateLikeDto } from './dto/create-like.dto';
@Injectable() @Injectable()
@@ -14,6 +15,7 @@ export class LikesService {
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
private readonly redisService: RedisService, private readonly redisService: RedisService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly chatGateway: ChatGateway,
) {} ) {}
async createLike(userId: string, dto: CreateLikeDto) { async createLike(userId: string, dto: CreateLikeDto) {
@@ -23,11 +25,15 @@ export class LikesService {
throw new BadRequestException('Cannot like yourself'); throw new BadRequestException('Cannot like yourself');
} }
const maxMatches = this.configService.get<number>('app.maxMatchesBeforePause'); const [sourceProfile] = await this.drizzleService.db
const activeMatchesCount = await this.getMatchesCount(dto.sourceProfileId); .select({ activeChatId: profile.activeChatId })
if (activeMatchesCount >= maxMatches) { .from(profile)
.where(eq(profile.id, dto.sourceProfileId))
.limit(1);
if (sourceProfile?.activeChatId) {
throw new BadRequestException( throw new BadRequestException(
`Profile has ${activeMatchesCount} matches. Resolve them before searching for new ones.`, 'У вас уже есть активный матч. Завершите текущий диалог перед поиском.',
); );
} }
@@ -57,7 +63,7 @@ export class LikesService {
return this.checkAndCreateMatch(dto.sourceProfileId, dto.targetProfileId, newLike); return this.checkAndCreateMatch(dto.sourceProfileId, dto.targetProfileId, newLike);
} }
return { like: newLike, match: null }; return { like: newLike, match: null, chat: null };
} }
async getMyMatches(userId: string, profileId: string) { async getMyMatches(userId: string, profileId: string) {
@@ -82,7 +88,17 @@ export class LikesService {
) )
.limit(1); .limit(1);
if (reverseLike.length === 0) return { like: newLike, match: null }; if (reverseLike.length === 0) return { like: newLike, match: null, chat: null };
const [targetProfile] = await this.drizzleService.db
.select({ activeChatId: profile.activeChatId })
.from(profile)
.where(eq(profile.id, profileId2))
.limit(1);
if (targetProfile?.activeChatId) {
return { like: newLike, match: null, chat: null };
}
const existingMatch = await this.drizzleService.db const existingMatch = await this.drizzleService.db
.select() .select()
@@ -95,27 +111,56 @@ export class LikesService {
) )
.limit(1); .limit(1);
if (existingMatch.length > 0) return { like: newLike, match: existingMatch[0] }; if (existingMatch.length > 0) {
const existingChat = await this.drizzleService.db
.select()
.from(chat)
.where(
or(
and(eq(chat.profile1Id, profileId1), eq(chat.profile2Id, profileId2)),
and(eq(chat.profile1Id, profileId2), eq(chat.profile2Id, profileId1)),
),
)
.limit(1);
return { like: newLike, match: existingMatch[0], chat: existingChat[0] ?? null };
}
const [newMatch] = await this.drizzleService.db const [newMatch] = await this.drizzleService.db
.insert(match) .insert(match)
.values({ profile1Id: profileId1, profile2Id: profileId2 }) .values({ profile1Id: profileId1, profile2Id: profileId2 })
.returning(); .returning();
await this.notifyMatch(profileId1, profileId2, newMatch.id); const [newChat] = await this.drizzleService.db
.insert(chat)
.values({
profile1Id: profileId1,
profile2Id: profileId2,
status: 'active',
} as any)
.returning();
return { like: newLike, match: newMatch }; await this.drizzleService.db
.update(profile)
.set({ activeChatId: newChat.id } as any)
.where(or(eq(profile.id, profileId1), eq(profile.id, profileId2)));
await this.notifyMatch(profileId1, profileId2, newMatch.id, newChat.id);
return { like: newLike, match: newMatch, chat: newChat };
} }
private async getMatchesCount(profileId: string): Promise<number> { private async notifyMatch(profileId1: string, profileId2: string, matchId: string, chatId: string) {
const matches = await this.drizzleService.db this.chatGateway.emitToProfile(profileId1, 'match_created', {
.select({ id: match.id }) matchId,
.from(match) chatId,
.where(or(eq(match.profile1Id, profileId), eq(match.profile2Id, profileId))); partnerProfileId: profileId2,
return matches.length; });
} this.chatGateway.emitToProfile(profileId2, 'match_created', {
matchId,
chatId,
partnerProfileId: profileId1,
});
private async notifyMatch(profileId1: string, profileId2: string, matchId: string) {
const profiles = await this.drizzleService.db const profiles = await this.drizzleService.db
.select({ userId: profile.userId }) .select({ userId: profile.userId })
.from(profile) .from(profile)
@@ -131,16 +176,16 @@ export class LikesService {
if (u?.fcmToken) { if (u?.fcmToken) {
await this.notificationsService.sendPushNotification( await this.notificationsService.sendPushNotification(
u.fcmToken, u.fcmToken,
'New Match!', 'Новый матч!',
'You have a new match! Start chatting now.', 'У вас новый матч. Начните общение прямо сейчас.',
{ matchId, type: 'match' }, { matchId, chatId, type: 'match_created' },
); );
} }
} }
await this.redisService.publish( await this.redisService.publish(
'match:created', 'match:created',
JSON.stringify({ matchId, profileId1, profileId2 }), JSON.stringify({ matchId, chatId, profileId1, profileId2 }),
); );
} }

View File

@@ -1,5 +1,5 @@
import { Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiConsumes, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -17,6 +17,15 @@ export class MediaController {
@Post('upload') @Post('upload')
@ApiOperation({ summary: 'Upload photo / video / audio to profile' }) @ApiOperation({ summary: 'Upload photo / video / audio to profile' })
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['file'],
properties: {
file: { type: 'string', format: 'binary' },
},
},
})
@ApiCreatedResponse({ type: MediaItemDto }) @ApiCreatedResponse({ type: MediaItemDto })
async upload( async upload(
@CurrentUser('id') userId: string, @CurrentUser('id') userId: string,

View File

@@ -31,6 +31,20 @@ export class StorageService implements OnModuleInit {
await this.client.makeBucket(this.bucket); await this.client.makeBucket(this.bucket);
this.logger.log(`Bucket "${this.bucket}" created`); this.logger.log(`Bucket "${this.bucket}" created`);
} }
await this.client.setBucketPolicy(
this.bucket,
JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${this.bucket}/*`],
},
],
}),
);
} }
async uploadFile( async uploadFile(