✨ 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:
@@ -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 | ссылка на активный чат (один профиль — один чат одновременно) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
31
src/modules/chat/dto/close-chat.dto.ts
Normal file
31
src/modules/chat/dto/close-chat.dto.ts
Normal 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;
|
||||
}
|
||||
71
src/modules/dev/dev.controller.ts
Normal file
71
src/modules/dev/dev.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/modules/dev/dev.module.ts
Normal file
7
src/modules/dev/dev.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DevController } from './dev.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [DevController],
|
||||
})
|
||||
export class DevModule {}
|
||||
@@ -29,4 +29,4 @@ export class FeedController {
|
||||
getFeed(@CurrentUser('id') userId: string, @Query() filter: FeedFilterDto) {
|
||||
return this.feedService.getFeed(userId, filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<number>('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<number> {
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user