diff --git a/database-schema.md b/database-schema.md index 290b42e..9f7ea0b 100644 --- a/database-schema.md +++ b/database-schema.md @@ -20,7 +20,6 @@ | role_id | uuid FK → role | роль пользователя | | tariff_id | uuid FK → tariff | текущий тариф | | payment_id | uuid FK → payment | способ оплаты | -| active_chat_id | uuid FK → chat | активный чат (один пользователь — один чат одновременно) | | fcm_token | string | токен для push-уведомлений (FCM) | --- @@ -40,6 +39,7 @@ | nation | string | национальность | | height | float | опционально | | weight | float | опционально | +| active_chat_id | uuid | ссылка на активный чат (один профиль — один чат одновременно) | --- diff --git a/src/app.module.ts b/src/app.module.ts index a58b5ac..ae7ee69 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { TagsModule } from './modules/tags/tags.module'; import { CitiesModule } from './modules/cities/cities.module'; import { GreetingsModule } from './modules/greetings/greetings.module'; import { GatewaysModule } from './gateways/gateways.module'; +import { DevModule } from './modules/dev/dev.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { AllExceptionsFilter } from './common/filters/http-exception.filter'; @@ -57,6 +58,7 @@ import { TransformInterceptor } from './common/interceptors/transform.intercepto CitiesModule, GreetingsModule, GatewaysModule, + ...(process.env.NODE_ENV !== 'production' ? [DevModule] : []), ], providers: [ { diff --git a/src/modules/chat/chat.controller.ts b/src/modules/chat/chat.controller.ts index 120fa33..00d04cd 100644 --- a/src/modules/chat/chat.controller.ts +++ b/src/modules/chat/chat.controller.ts @@ -4,6 +4,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { ChatService } from './chat.service'; import { CreateChatDto } from './dto/create-chat.dto'; +import { CloseChatDto } from './dto/close-chat.dto'; import { SendMessageDto } from './dto/send-message.dto'; import { ChatDto, MessageDto } from './dto/chat-response.dto'; import { MessageResponseDto } from '../../common/dto/message-response.dto'; @@ -60,13 +61,14 @@ export class ChatController { } @Delete(':chatId') - @ApiOperation({ summary: 'Close a chat' }) + @ApiOperation({ summary: 'Close a chat (cancel match or report)' }) @ApiOkResponse({ type: MessageResponseDto }) closeChat( @CurrentUser('id') userId: string, @Query('profileId') profileId: string, @Param('chatId') chatId: string, + @Body() dto: CloseChatDto, ) { - return this.chatService.closeChat(userId, profileId, chatId); + return this.chatService.closeChat(userId, profileId, chatId, dto); } } diff --git a/src/modules/chat/chat.service.ts b/src/modules/chat/chat.service.ts index d2419e2..c48ef07 100644 --- a/src/modules/chat/chat.service.ts +++ b/src/modules/chat/chat.service.ts @@ -6,9 +6,10 @@ import { } from '@nestjs/common'; import { and, eq, or } from 'drizzle-orm'; 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 { CreateChatDto } from './dto/create-chat.dto'; +import { CloseChatDto, CloseChatType } from './dto/close-chat.dto'; import { SendMessageDto } from './dto/send-message.dto'; @Injectable() @@ -76,7 +77,7 @@ export class ChatService { 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); const [foundChat] = await this.drizzleService.db @@ -90,6 +91,39 @@ export class ChatService { 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 .update(chat) .set({ status: 'closed' } as any) @@ -108,12 +142,7 @@ export class ChatService { return this.drizzleService.db .select() .from(chat) - .where( - and( - or(eq(chat.profile1Id, profileId), eq(chat.profile2Id, profileId)), - eq(chat.status, 'active'), - ), - ); + .where(or(eq(chat.profile1Id, profileId), eq(chat.profile2Id, profileId))); } async getChatMessages(userId: string, profileId: string, chatId: string, page = 1, limit = 50) { diff --git a/src/modules/chat/dto/close-chat.dto.ts b/src/modules/chat/dto/close-chat.dto.ts new file mode 100644 index 0000000..fd4423d --- /dev/null +++ b/src/modules/chat/dto/close-chat.dto.ts @@ -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; +} diff --git a/src/modules/dev/dev.controller.ts b/src/modules/dev/dev.controller.ts new file mode 100644 index 0000000..27f769f --- /dev/null +++ b/src/modules/dev/dev.controller.ts @@ -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, + }; + } +} diff --git a/src/modules/dev/dev.module.ts b/src/modules/dev/dev.module.ts new file mode 100644 index 0000000..e2fe239 --- /dev/null +++ b/src/modules/dev/dev.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { DevController } from './dev.controller'; + +@Module({ + controllers: [DevController], +}) +export class DevModule {} diff --git a/src/modules/feed/feed.controller.ts b/src/modules/feed/feed.controller.ts index e87e718..16cf6ad 100644 --- a/src/modules/feed/feed.controller.ts +++ b/src/modules/feed/feed.controller.ts @@ -29,4 +29,4 @@ export class FeedController { getFeed(@CurrentUser('id') userId: string, @Query() filter: FeedFilterDto) { return this.feedService.getFeed(userId, filter); } -} +} \ No newline at end of file diff --git a/src/modules/feed/feed.service.ts b/src/modules/feed/feed.service.ts index 43dbe0b..da54b9a 100644 --- a/src/modules/feed/feed.service.ts +++ b/src/modules/feed/feed.service.ts @@ -1,5 +1,5 @@ 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 { like, profile, profileMedia, profileTag, tag, user } from '../../database/schema'; import { FeedFilterDto } from './dto/feed-filter.dto'; @@ -24,6 +24,7 @@ export class FeedService { const conditions: any[] = [ ne(profile.id, profileId), ne(user.status, 'banned'), + isNull(profile.activeChatId), ]; if (interactedIds.length > 0) { diff --git a/src/modules/likes/dto/likes-response.dto.ts b/src/modules/likes/dto/likes-response.dto.ts index cb1ab49..9f660ba 100644 --- a/src/modules/likes/dto/likes-response.dto.ts +++ b/src/modules/likes/dto/likes-response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ChatDto } from '../../chat/dto/chat-response.dto'; export class LikeDto { @ApiProperty() id: string; @@ -18,4 +19,5 @@ export class MatchDto { export class CreateLikeResponseDto { @ApiProperty({ type: LikeDto }) like: LikeDto; @ApiPropertyOptional({ type: MatchDto, nullable: true }) match: MatchDto | null; + @ApiPropertyOptional({ type: ChatDto, nullable: true }) chat: ChatDto | null; } diff --git a/src/modules/likes/likes.module.ts b/src/modules/likes/likes.module.ts index caade68..c46d5c9 100644 --- a/src/modules/likes/likes.module.ts +++ b/src/modules/likes/likes.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { LikesController } from './likes.controller'; import { LikesService } from './likes.service'; +import { GatewaysModule } from '../../gateways/gateways.module'; @Module({ + imports: [GatewaysModule], controllers: [LikesController], providers: [LikesService], }) diff --git a/src/modules/likes/likes.service.ts b/src/modules/likes/likes.service.ts index 5cfe15e..0ea884b 100644 --- a/src/modules/likes/likes.service.ts +++ b/src/modules/likes/likes.service.ts @@ -2,9 +2,10 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException import { and, eq, or } from 'drizzle-orm'; import { ConfigService } from '@nestjs/config'; 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 { RedisService } from '../../redis/redis.service'; +import { ChatGateway } from '../../gateways/chat.gateway'; import { CreateLikeDto } from './dto/create-like.dto'; @Injectable() @@ -14,6 +15,7 @@ export class LikesService { private readonly notificationsService: NotificationsService, private readonly redisService: RedisService, private readonly configService: ConfigService, + private readonly chatGateway: ChatGateway, ) {} async createLike(userId: string, dto: CreateLikeDto) { @@ -23,11 +25,15 @@ export class LikesService { throw new BadRequestException('Cannot like yourself'); } - const maxMatches = this.configService.get('app.maxMatchesBeforePause'); - const activeMatchesCount = await this.getMatchesCount(dto.sourceProfileId); - if (activeMatchesCount >= maxMatches) { + const [sourceProfile] = await this.drizzleService.db + .select({ activeChatId: profile.activeChatId }) + .from(profile) + .where(eq(profile.id, dto.sourceProfileId)) + .limit(1); + + if (sourceProfile?.activeChatId) { 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 { like: newLike, match: null }; + return { like: newLike, match: null, chat: null }; } async getMyMatches(userId: string, profileId: string) { @@ -82,7 +88,17 @@ export class LikesService { ) .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 .select() @@ -95,27 +111,56 @@ export class LikesService { ) .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 .insert(match) .values({ profile1Id: profileId1, profile2Id: profileId2 }) .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 { - const matches = await this.drizzleService.db - .select({ id: match.id }) - .from(match) - .where(or(eq(match.profile1Id, profileId), eq(match.profile2Id, profileId))); - return matches.length; - } + private async notifyMatch(profileId1: string, profileId2: string, matchId: string, chatId: string) { + this.chatGateway.emitToProfile(profileId1, 'match_created', { + matchId, + chatId, + partnerProfileId: profileId2, + }); + 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 .select({ userId: profile.userId }) .from(profile) @@ -131,16 +176,16 @@ export class LikesService { if (u?.fcmToken) { await this.notificationsService.sendPushNotification( u.fcmToken, - 'New Match!', - 'You have a new match! Start chatting now.', - { matchId, type: 'match' }, + 'Новый матч!', + 'У вас новый матч. Начните общение прямо сейчас.', + { matchId, chatId, type: 'match_created' }, ); } } await this.redisService.publish( 'match:created', - JSON.stringify({ matchId, profileId1, profileId2 }), + JSON.stringify({ matchId, chatId, profileId1, profileId2 }), ); } diff --git a/src/modules/media/media.controller.ts b/src/modules/media/media.controller.ts index bb60a01..b40bef6 100644 --- a/src/modules/media/media.controller.ts +++ b/src/modules/media/media.controller.ts @@ -1,5 +1,5 @@ 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 { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -17,6 +17,15 @@ export class MediaController { @Post('upload') @ApiOperation({ summary: 'Upload photo / video / audio to profile' }) @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file'], + properties: { + file: { type: 'string', format: 'binary' }, + }, + }, + }) @ApiCreatedResponse({ type: MediaItemDto }) async upload( @CurrentUser('id') userId: string, diff --git a/src/storage/storage.service.ts b/src/storage/storage.service.ts index 7e020ab..e0aead2 100644 --- a/src/storage/storage.service.ts +++ b/src/storage/storage.service.ts @@ -31,6 +31,20 @@ export class StorageService implements OnModuleInit { await this.client.makeBucket(this.bucket); 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(