This commit is contained in:
Oscar
2026-06-02 16:22:53 +03:00
parent dc44cdd639
commit bc3e48bcad
37 changed files with 973 additions and 1894 deletions

View File

@@ -1,13 +1,4 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -24,46 +15,49 @@ export class ChatController {
@Post()
@ApiOperation({ summary: 'Open a chat for a match' })
createChat(
@CurrentUser('id') userId: string,
@Body() dto: CreateChatDto,
) {
createChat(@CurrentUser('id') userId: string, @Body() dto: CreateChatDto) {
return this.chatService.createChat(userId, dto);
}
@Get()
@ApiOperation({ summary: 'Get my active chats' })
getMyChats(@CurrentUser('id') userId: string) {
return this.chatService.getMyChats(userId);
@ApiOperation({ summary: 'Get active chats for a profile' })
getChats(
@CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
) {
return this.chatService.getChatsForProfile(userId, profileId);
}
@Get(':chatId/messages')
@ApiOperation({ summary: 'Get chat messages' })
getMessages(
@CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('chatId') chatId: string,
@Query('page') page = 1,
@Query('limit') limit = 50,
) {
return this.chatService.getChatMessages(userId, chatId, +page, +limit);
return this.chatService.getChatMessages(userId, profileId, chatId, +page, +limit);
}
@Post(':chatId/messages')
@ApiOperation({ summary: 'Send a message' })
sendMessage(
@CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('chatId') chatId: string,
@Body() dto: SendMessageDto,
) {
return this.chatService.sendMessage(userId, chatId, dto);
return this.chatService.sendMessage(userId, profileId, chatId, dto);
}
@Delete(':chatId')
@ApiOperation({ summary: 'Close a chat' })
closeChat(
@CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('chatId') chatId: string,
) {
return this.chatService.closeChat(userId, chatId);
return this.chatService.closeChat(userId, profileId, chatId);
}
}

View File

@@ -19,13 +19,15 @@ export class ChatService {
) {}
async createChat(userId: string, dto: CreateChatDto) {
await this.assertProfileOwnership(userId, dto.profileId);
const [foundMatch] = await this.drizzleService.db
.select()
.from(match)
.where(
and(
eq(match.id, dto.matchId),
or(eq(match.user1Id, userId), eq(match.user2Id, userId)),
or(eq(match.profile1Id, dto.profileId), eq(match.profile2Id, dto.profileId)),
),
)
.limit(1);
@@ -37,50 +39,46 @@ export class ChatService {
.from(chat)
.where(
or(
and(
eq(chat.profile1Id, foundMatch.user1Id),
eq(chat.profile2Id, foundMatch.user2Id),
),
and(
eq(chat.profile1Id, foundMatch.user2Id),
eq(chat.profile2Id, foundMatch.user1Id),
),
and(eq(chat.profile1Id, foundMatch.profile1Id), eq(chat.profile2Id, foundMatch.profile2Id)),
and(eq(chat.profile1Id, foundMatch.profile2Id), eq(chat.profile2Id, foundMatch.profile1Id)),
),
)
.limit(1);
if (existingChat.length > 0) return existingChat[0];
const currentUser = await this.drizzleService.db
.select({ activeChatId: user.activeChatId })
.from(user)
.where(eq(user.id, userId))
const [currentProfile] = await this.drizzleService.db
.select({ activeChatId: profile.activeChatId })
.from(profile)
.where(eq(profile.id, dto.profileId))
.limit(1);
if (currentUser[0]?.activeChatId) {
if (currentProfile?.activeChatId) {
throw new BadRequestException(
'You already have an active chat. Close it before opening a new one.',
'Profile already has an active chat. Close it before opening a new one.',
);
}
const [newChat] = await this.drizzleService.db
.insert(chat)
.values({
profile1Id: foundMatch.user1Id,
profile2Id: foundMatch.user2Id,
profile1Id: foundMatch.profile1Id,
profile2Id: foundMatch.profile2Id,
status: 'active',
} as any)
.returning();
await this.drizzleService.db
.update(user)
.update(profile)
.set({ activeChatId: newChat.id } as any)
.where(or(eq(user.id, foundMatch.user1Id), eq(user.id, foundMatch.user2Id)));
.where(or(eq(profile.id, foundMatch.profile1Id), eq(profile.id, foundMatch.profile2Id)));
return newChat;
}
async closeChat(userId: string, chatId: string) {
async closeChat(userId: string, profileId: string, chatId: string) {
await this.assertProfileOwnership(userId, profileId);
const [foundChat] = await this.drizzleService.db
.select()
.from(chat)
@@ -88,7 +86,7 @@ export class ChatService {
.limit(1);
if (!foundChat) throw new NotFoundException('Chat not found');
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) {
if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) {
throw new ForbiddenException('Not a chat participant');
}
@@ -98,26 +96,29 @@ export class ChatService {
.where(eq(chat.id, chatId));
await this.drizzleService.db
.update(user)
.update(profile)
.set({ activeChatId: null } as any)
.where(or(eq(user.id, foundChat.profile1Id), eq(user.id, foundChat.profile2Id)));
.where(or(eq(profile.id, foundChat.profile1Id), eq(profile.id, foundChat.profile2Id)));
return { message: 'Chat closed' };
}
async getMyChats(userId: string) {
async getChatsForProfile(userId: string, profileId: string) {
await this.assertProfileOwnership(userId, profileId);
return this.drizzleService.db
.select()
.from(chat)
.where(
and(
or(eq(chat.profile1Id, userId), eq(chat.profile2Id, userId)),
or(eq(chat.profile1Id, profileId), eq(chat.profile2Id, profileId)),
eq(chat.status, 'active'),
),
);
}
async getChatMessages(userId: string, chatId: string, page = 1, limit = 50) {
async getChatMessages(userId: string, profileId: string, chatId: string, page = 1, limit = 50) {
await this.assertProfileOwnership(userId, profileId);
const [foundChat] = await this.drizzleService.db
.select()
.from(chat)
@@ -125,7 +126,7 @@ export class ChatService {
.limit(1);
if (!foundChat) throw new NotFoundException('Chat not found');
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) {
if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) {
throw new ForbiddenException('Not a chat participant');
}
@@ -139,7 +140,9 @@ export class ChatService {
.offset(offset);
}
async sendMessage(userId: string, chatId: string, dto: SendMessageDto) {
async sendMessage(userId: string, profileId: string, chatId: string, dto: SendMessageDto) {
await this.assertProfileOwnership(userId, profileId);
const [foundChat] = await this.drizzleService.db
.select()
.from(chat)
@@ -147,7 +150,7 @@ export class ChatService {
.limit(1);
if (!foundChat) throw new NotFoundException('Active chat not found');
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) {
if (foundChat.profile1Id !== profileId && foundChat.profile2Id !== profileId) {
throw new ForbiddenException('Not a chat participant');
}
@@ -159,31 +162,50 @@ export class ChatService {
.insert(message)
.values({
chatId,
userId,
profileId,
text: dto.text || null,
mediaUrl: dto.mediaUrl || null,
mediaType: dto.mediaType || null,
} as any)
.returning();
const recipientId =
foundChat.profile1Id === userId ? foundChat.profile2Id : foundChat.profile1Id;
const recipientProfileId =
foundChat.profile1Id === profileId ? foundChat.profile2Id : foundChat.profile1Id;
const [recipient] = await this.drizzleService.db
.select({ fcmToken: user.fcmToken })
.from(user)
.where(eq(user.id, recipientId))
const [recipientProfile] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, recipientProfileId))
.limit(1);
if (recipient?.fcmToken) {
await this.notificationsService.sendPushNotification(
recipient.fcmToken,
'New message',
dto.text?.substring(0, 100) || 'Media message',
{ chatId, messageId: newMessage.id, type: 'message' },
);
if (recipientProfile) {
const [recipientUser] = await this.drizzleService.db
.select({ fcmToken: user.fcmToken })
.from(user)
.where(eq(user.id, recipientProfile.userId))
.limit(1);
if (recipientUser?.fcmToken) {
await this.notificationsService.sendPushNotification(
recipientUser.fcmToken,
'New message',
dto.text?.substring(0, 100) || 'Media message',
{ chatId, messageId: newMessage.id, type: 'message' },
);
}
}
return newMessage;
}
private async assertProfileOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
}

View File

@@ -2,6 +2,10 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class CreateChatDto {
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
profileId: string;
@ApiProperty({ description: 'Match ID to open chat for' })
@IsUUID()
matchId: string;

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -14,28 +14,29 @@ export class DatesController {
constructor(private readonly datesService: DatesService) {}
@Post()
@ApiOperation({ summary: 'Propose a date/meetup' })
create(
@CurrentUser('id') userId: string,
@Body() dto: CreateDateDto,
) {
@ApiOperation({ summary: 'Propose a meetup' })
create(@CurrentUser('id') userId: string, @Body() dto: CreateDateDto) {
return this.datesService.create(userId, dto);
}
@Get()
@ApiOperation({ summary: 'Get my dates' })
getMyDates(@CurrentUser('id') userId: string) {
return this.datesService.getMyDates(userId);
@ApiOperation({ summary: 'Get dates for a profile' })
getDates(
@CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
) {
return this.datesService.getForProfile(userId, profileId);
}
@Patch(':id/status')
@ApiOperation({ summary: 'Update date status' })
updateStatus(
@CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
@Param('id') id: string,
@Body() dto: UpdateDateStatusDto,
) {
return this.datesService.updateStatus(userId, id, dto);
return this.datesService.updateStatus(userId, profileId, id, dto);
}
@Get('statuses')

View File

@@ -1,7 +1,7 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { and, eq, or } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service';
import { date, dateStatus } from '../../database/schema';
import { date, dateStatus, profile } from '../../database/schema';
import { CreateDateDto } from './dto/create-date.dto';
import { UpdateDateStatusDto } from './dto/update-date-status.dto';
@@ -10,8 +10,9 @@ export class DatesService {
constructor(private readonly drizzleService: DrizzleService) {}
async create(userId: string, dto: CreateDateDto) {
let statusId = dto.statusId;
await this.assertProfileOwnership(userId, dto.profileId);
let statusId = dto.statusId;
if (!statusId) {
const [pending] = await this.drizzleService.db
.select({ id: dateStatus.id })
@@ -24,8 +25,8 @@ export class DatesService {
const [newDate] = await this.drizzleService.db
.insert(date)
.values({
user1Id: userId,
user2Id: dto.partnerId,
profile1Id: dto.profileId,
profile2Id: dto.partnerProfileId,
lat: dto.lat.toString(),
lng: dto.lng.toString(),
time: new Date(dto.time),
@@ -36,16 +37,19 @@ export class DatesService {
return newDate;
}
async getMyDates(userId: string) {
async getForProfile(userId: string, profileId: string) {
await this.assertProfileOwnership(userId, profileId);
return this.drizzleService.db
.select()
.from(date)
.leftJoin(dateStatus, eq(dateStatus.id, date.statusId))
.where(or(eq(date.user1Id, userId), eq(date.user2Id, userId)))
.where(or(eq(date.profile1Id, profileId), eq(date.profile2Id, profileId)))
.orderBy(date.time);
}
async updateStatus(userId: string, dateId: string, dto: UpdateDateStatusDto) {
async updateStatus(userId: string, profileId: string, dateId: string, dto: UpdateDateStatusDto) {
await this.assertProfileOwnership(userId, profileId);
const [found] = await this.drizzleService.db
.select()
.from(date)
@@ -53,7 +57,7 @@ export class DatesService {
.limit(1);
if (!found) throw new NotFoundException('Date not found');
if (found.user1Id !== userId && found.user2Id !== userId) {
if (found.profile1Id !== profileId && found.profile2Id !== profileId) {
throw new ForbiddenException('Not a participant');
}
@@ -69,4 +73,15 @@ export class DatesService {
async getStatuses() {
return this.drizzleService.db.select().from(dateStatus);
}
private async assertProfileOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
}

View File

@@ -2,9 +2,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator';
export class CreateDateDto {
@ApiProperty()
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
partnerId: string;
profileId: string;
@ApiProperty({ description: 'Partner profile ID' })
@IsUUID()
partnerProfileId: string;
@ApiProperty()
@IsNumber()

View File

@@ -1,8 +1,12 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class FeedFilterDto {
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
profileId: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@@ -18,24 +22,16 @@ export class FeedFilterDto {
@Max(50)
limit?: number = 20;
@ApiPropertyOptional({ description: 'City UUID filter' })
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
cityId?: string;
@ApiPropertyOptional({ description: 'District UUID filter' })
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
districtId?: string;
@ApiPropertyOptional({ description: 'Search radius in km' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(500)
radiusKm?: number;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@@ -50,12 +46,12 @@ export class FeedFilterDto {
@Max(100)
ageMax?: number;
@ApiPropertyOptional({ description: 'Search keyword in description/name' })
@ApiPropertyOptional()
@IsOptional()
@IsString()
keyword?: string;
@ApiPropertyOptional({ type: [String], description: 'Tag IDs to filter' })
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsUUID(undefined, { each: true })

View File

@@ -13,11 +13,8 @@ export class FeedController {
constructor(private readonly feedService: FeedService) {}
@Get()
@ApiOperation({ summary: 'Get filtered feed of profiles' })
getFeed(
@CurrentUser('id') userId: string,
@Query() filter: FeedFilterDto,
) {
@ApiOperation({ summary: 'Get filtered feed (requires profileId)' })
getFeed(@CurrentUser('id') userId: string, @Query() filter: FeedFilterDto) {
return this.feedService.getFeed(userId, filter);
}
}

View File

@@ -1,31 +1,33 @@
import { Injectable } 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 { DrizzleService } from '../../database/drizzle.service';
import { like, match, profile, profileTag, tag, user } from '../../database/schema';
import { like, profile, profileMedia, profileTag, tag, user } from '../../database/schema';
import { FeedFilterDto } from './dto/feed-filter.dto';
@Injectable()
export class FeedService {
constructor(private readonly drizzleService: DrizzleService) {}
async getFeed(currentUserId: string, filter: FeedFilterDto) {
const { page = 1, limit = 20, cityId, districtId, ageMin, ageMax, keyword, tagIds } = filter;
async getFeed(userId: string, filter: FeedFilterDto) {
const { profileId, page = 1, limit = 20, cityId, districtId, ageMin, ageMax, keyword, tagIds } = filter;
await this.assertProfileOwnership(userId, profileId);
const offset = (page - 1) * limit;
const alreadyInteracted = await this.drizzleService.db
.select({ targetUser: like.targetUser })
.select({ targetProfileId: like.targetProfileId })
.from(like)
.where(eq(like.sourceUser, currentUserId));
.where(eq(like.sourceProfileId, profileId));
const interactedIds = alreadyInteracted.map((r) => r.targetUser);
const interactedIds = alreadyInteracted.map((r) => r.targetProfileId);
const conditions: any[] = [
ne(profile.userId, currentUserId),
ne(profile.id, profileId),
ne(user.status, 'banned'),
];
if (interactedIds.length > 0) {
conditions.push(notInArray(profile.userId, interactedIds));
conditions.push(notInArray(profile.id, interactedIds));
}
if (cityId) conditions.push(eq(profile.cityId, cityId));
@@ -52,19 +54,15 @@ export class FeedService {
);
}
let profileIds: string[] | null = null;
if (tagIds?.length) {
const tagMatches = await this.drizzleService.db
.select({ profileId: profileTag.profileId })
.from(profileTag)
.where(inArray(profileTag.tagId, tagIds));
profileIds = tagMatches.map((r) => r.profileId);
if (profileIds.length > 0) {
conditions.push(inArray(profile.id, profileIds));
} else {
return { data: [], total: 0, page, limit };
}
const matchedIds = tagMatches.map((r) => r.profileId);
if (matchedIds.length === 0) return { data: [], page, limit };
conditions.push(inArray(profile.id, matchedIds));
}
const rows = await this.drizzleService.db
@@ -73,6 +71,7 @@ export class FeedService {
userId: profile.userId,
name: profile.name,
birthDate: profile.birthDate,
gender: profile.gender,
cityId: profile.cityId,
districtId: profile.districtId,
description: profile.description,
@@ -89,14 +88,19 @@ export class FeedService {
const enriched = await Promise.all(
rows.map(async (p) => {
const tags = await this.drizzleService.db
.select({ id: tag.id, value: tag.value })
.from(profileTag)
.innerJoin(tag, eq(tag.id, profileTag.tagId))
.where(eq(profileTag.profileId, p.id));
const age = this.calculateAge(p.birthDate);
return { ...p, age, tags };
const [tags, media] = await Promise.all([
this.drizzleService.db
.select({ id: tag.id, value: tag.value })
.from(profileTag)
.innerJoin(tag, eq(tag.id, profileTag.tagId))
.where(eq(profileTag.profileId, p.id)),
this.drizzleService.db
.select()
.from(profileMedia)
.where(eq(profileMedia.profileId, p.id))
.orderBy(profileMedia.sortOrder),
]);
return { ...p, age: this.calculateAge(p.birthDate), tags, media };
}),
);
@@ -111,4 +115,15 @@ export class FeedService {
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return age;
}
private async assertProfileOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
}

View File

@@ -2,9 +2,13 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsUUID } from 'class-validator';
export class CreateLikeDto {
@ApiProperty()
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
targetUserId: string;
sourceProfileId: string;
@ApiProperty({ description: 'Target profile ID' })
@IsUUID()
targetProfileId: string;
@ApiProperty({ enum: ['like', 'dislike'] })
@IsEnum(['like', 'dislike'])

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -13,17 +13,17 @@ export class LikesController {
constructor(private readonly likesService: LikesService) {}
@Post()
@ApiOperation({ summary: 'Like or dislike a user' })
createLike(
@CurrentUser('id') userId: string,
@Body() dto: CreateLikeDto,
) {
@ApiOperation({ summary: 'Like or dislike a profile' })
createLike(@CurrentUser('id') userId: string, @Body() dto: CreateLikeDto) {
return this.likesService.createLike(userId, dto);
}
@Get('matches')
@ApiOperation({ summary: 'Get my matches' })
getMyMatches(@CurrentUser('id') userId: string) {
return this.likesService.getMyMatches(userId);
@ApiOperation({ summary: 'Get matches for a profile' })
getMyMatches(
@CurrentUser('id') userId: string,
@Query('profileId') profileId: string,
) {
return this.likesService.getMyMatches(userId, profileId);
}
}

View File

@@ -1,13 +1,10 @@
import {
BadRequestException,
Injectable,
} from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { and, eq, or } from 'drizzle-orm';
import { ConfigService } from '@nestjs/config';
import { DrizzleService } from '../../database/drizzle.service';
import { like, match, user } from '../../database/schema';
import { like, match, profile, user } from '../../database/schema';
import { NotificationsService } from '../../notifications/notifications.service';
import { RedisService } from '../../redis/redis.service';
import { ConfigService } from '@nestjs/config';
import { CreateLikeDto } from './dto/create-like.dto';
@Injectable()
@@ -19,16 +16,18 @@ export class LikesService {
private readonly configService: ConfigService,
) {}
async createLike(sourceUserId: string, dto: CreateLikeDto) {
if (sourceUserId === dto.targetUserId) {
async createLike(userId: string, dto: CreateLikeDto) {
await this.assertProfileOwnership(userId, dto.sourceProfileId);
if (dto.sourceProfileId === dto.targetProfileId) {
throw new BadRequestException('Cannot like yourself');
}
const maxMatches = this.configService.get<number>('app.maxMatchesBeforePause');
const activeMatchesCount = await this.getActiveMatchesCount(sourceUserId);
const activeMatchesCount = await this.getMatchesCount(dto.sourceProfileId);
if (activeMatchesCount >= maxMatches) {
throw new BadRequestException(
`You have ${activeMatchesCount} matches. Resolve them before searching for new ones.`,
`Profile has ${activeMatchesCount} matches. Resolve them before searching for new ones.`,
);
}
@@ -37,92 +36,99 @@ export class LikesService {
.from(like)
.where(
and(
eq(like.sourceUser, sourceUserId),
eq(like.targetUser, dto.targetUserId),
eq(like.sourceProfileId, dto.sourceProfileId),
eq(like.targetProfileId, dto.targetProfileId),
),
)
.limit(1);
if (existing.length > 0) {
throw new BadRequestException('Already reacted to this user');
}
if (existing.length > 0) throw new BadRequestException('Already reacted to this profile');
const [newLike] = await this.drizzleService.db
.insert(like)
.values({
sourceUser: sourceUserId,
targetUser: dto.targetUserId,
sourceProfileId: dto.sourceProfileId,
targetProfileId: dto.targetProfileId,
type: dto.type,
})
.returning();
if (dto.type === 'like') {
return this.checkAndCreateMatch(sourceUserId, dto.targetUserId, newLike);
return this.checkAndCreateMatch(dto.sourceProfileId, dto.targetProfileId, newLike);
}
return { like: newLike, match: null };
}
private async checkAndCreateMatch(userId1: string, userId2: string, newLike: any) {
async getMyMatches(userId: string, profileId: string) {
await this.assertProfileOwnership(userId, profileId);
return this.drizzleService.db
.select()
.from(match)
.where(or(eq(match.profile1Id, profileId), eq(match.profile2Id, profileId)))
.orderBy(match.createdAt);
}
private async checkAndCreateMatch(profileId1: string, profileId2: string, newLike: any) {
const reverseLike = await this.drizzleService.db
.select()
.from(like)
.where(
and(
eq(like.sourceUser, userId2),
eq(like.targetUser, userId1),
eq(like.sourceProfileId, profileId2),
eq(like.targetProfileId, profileId1),
eq(like.type, 'like'),
),
)
.limit(1);
if (reverseLike.length === 0) {
return { like: newLike, match: null };
}
if (reverseLike.length === 0) return { like: newLike, match: null };
const existingMatch = await this.drizzleService.db
.select()
.from(match)
.where(
or(
and(eq(match.user1Id, userId1), eq(match.user2Id, userId2)),
and(eq(match.user1Id, userId2), eq(match.user2Id, userId1)),
and(eq(match.profile1Id, profileId1), eq(match.profile2Id, profileId2)),
and(eq(match.profile1Id, profileId2), eq(match.profile2Id, profileId1)),
),
)
.limit(1);
if (existingMatch.length > 0) {
return { like: newLike, match: existingMatch[0] };
}
if (existingMatch.length > 0) return { like: newLike, match: existingMatch[0] };
const [newMatch] = await this.drizzleService.db
.insert(match)
.values({ user1Id: userId1, user2Id: userId2 })
.values({ profile1Id: profileId1, profile2Id: profileId2 })
.returning();
await this.notifyMatch(userId1, userId2, newMatch.id);
await this.notifyMatch(profileId1, profileId2, newMatch.id);
return { like: newLike, match: newMatch };
}
private async getActiveMatchesCount(userId: string): Promise<number> {
private async getMatchesCount(profileId: string): Promise<number> {
const matches = await this.drizzleService.db
.select({ id: match.id })
.from(match)
.where(
or(eq(match.user1Id, userId), eq(match.user2Id, userId)),
);
.where(or(eq(match.profile1Id, profileId), eq(match.profile2Id, profileId)));
return matches.length;
}
private async notifyMatch(userId1: string, userId2: string, matchId: string) {
const users = await this.drizzleService.db
.select({ id: user.id, fcmToken: user.fcmToken })
.from(user)
.where(or(eq(user.id, userId1), eq(user.id, userId2)));
private async notifyMatch(profileId1: string, profileId2: string, matchId: string) {
const profiles = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(or(eq(profile.id, profileId1), eq(profile.id, profileId2)));
for (const u of users) {
if (u.fcmToken) {
for (const p of profiles) {
const [u] = await this.drizzleService.db
.select({ fcmToken: user.fcmToken })
.from(user)
.where(eq(user.id, p.userId))
.limit(1);
if (u?.fcmToken) {
await this.notificationsService.sendPushNotification(
u.fcmToken,
'New Match!',
@@ -132,16 +138,20 @@ export class LikesService {
}
}
await this.redisService.publish('match:created', JSON.stringify({ matchId, userId1, userId2 }));
await this.redisService.publish(
'match:created',
JSON.stringify({ matchId, profileId1, profileId2 }),
);
}
async getMyMatches(userId: string) {
return this.drizzleService.db
.select()
.from(match)
.where(
or(eq(match.user1Id, userId), eq(match.user2Id, userId)),
)
.orderBy(match.createdAt);
private async assertProfileOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
}

View File

@@ -1,13 +1,4 @@
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, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FastifyRequest } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@@ -17,39 +8,42 @@ import { MediaService } from './media.service';
@ApiTags('media')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('media')
@Controller('profiles/:profileId/media')
export class MediaController {
constructor(private readonly mediaService: MediaService) {}
@Post('upload')
@ApiOperation({ summary: 'Upload photo or video' })
@ApiOperation({ summary: 'Upload photo / video / audio to profile' })
@ApiConsumes('multipart/form-data')
async upload(
@CurrentUser('id') userId: string,
@Param('profileId') profileId: string,
@Req() req: FastifyRequest,
@Query('type') type: 'photo' | 'video' = 'photo',
@Query('type') type: 'photo' | 'video' | 'audio' = 'photo',
) {
const data = await (req as any).file();
if (!data) {
throw new Error('No file provided');
}
if (!data) throw new Error('No file provided');
const buffer = await data.toBuffer();
return this.mediaService.uploadMedia(
return this.mediaService.upload(
userId,
profileId,
{ buffer, originalname: data.filename, mimetype: data.mimetype },
type,
);
}
@Get()
@ApiOperation({ summary: 'Get my media' })
getMyMedia(@CurrentUser('id') userId: string) {
return this.mediaService.getByUserId(userId);
@ApiOperation({ summary: 'Get all media for a profile' })
getMedia(@Param('profileId') profileId: string) {
return this.mediaService.getByProfileId(profileId);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete media' })
deleteMedia(@CurrentUser('id') userId: string, @Param('id') id: string) {
return this.mediaService.deleteMedia(userId, id);
@Delete(':mediaId')
@ApiOperation({ summary: 'Delete media item' })
deleteMedia(
@CurrentUser('id') userId: string,
@Param('mediaId') mediaId: string,
) {
return this.mediaService.delete(userId, mediaId);
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service';
import { media } from '../../database/schema';
import { profile, profileMedia } from '../../database/schema';
import { StorageService } from '../../storage/storage.service';
@Injectable()
@@ -11,50 +11,75 @@ export class MediaService {
private readonly storageService: StorageService,
) {}
async uploadMedia(
async upload(
userId: string,
profileId: string,
file: { buffer: Buffer; originalname: string; mimetype: string },
type: 'photo' | 'video',
type: 'photo' | 'video' | 'audio',
) {
const folder = type === 'photo' ? 'photos' : 'videos';
await this.assertOwnership(userId, profileId);
const folder = type;
const objectName = await this.storageService.uploadFile(
file.buffer,
file.originalname,
file.mimetype,
folder,
);
const publicUrl = this.storageService.getPublicUrl(objectName);
const path = this.storageService.getPublicUrl(objectName);
const existing = await this.drizzleService.db
.select({ sortOrder: profileMedia.sortOrder })
.from(profileMedia)
.where(eq(profileMedia.profileId, profileId))
.orderBy(profileMedia.sortOrder);
const nextOrder = existing.length > 0
? (existing[existing.length - 1].sortOrder ?? 0) + 1
: 0;
const [newMedia] = await this.drizzleService.db
.insert(media)
.values({ userId, path: publicUrl, type })
.insert(profileMedia)
.values({ profileId, path, type, sortOrder: nextOrder } as any)
.returning();
return newMedia;
}
async getByUserId(userId: string) {
async getByProfileId(profileId: string) {
return this.drizzleService.db
.select()
.from(media)
.where(eq(media.userId, userId));
.from(profileMedia)
.where(eq(profileMedia.profileId, profileId))
.orderBy(profileMedia.sortOrder);
}
async deleteMedia(userId: string, mediaId: string) {
async delete(userId: string, mediaId: string) {
const [found] = await this.drizzleService.db
.select()
.from(media)
.where(eq(media.id, mediaId))
.from(profileMedia)
.where(eq(profileMedia.id, mediaId))
.limit(1);
if (!found || found.userId !== userId) {
throw new NotFoundException('Media not found');
}
if (!found) throw new NotFoundException('Media not found');
await this.assertOwnership(userId, found.profileId);
const objectName = found.path.split('/').slice(-2).join('/');
await this.storageService.deleteFile(objectName).catch(() => {});
await this.drizzleService.db.delete(profileMedia).where(eq(profileMedia.id, mediaId));
await this.drizzleService.db.delete(media).where(eq(media.id, mediaId));
return { message: 'Media deleted' };
}
private async assertOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -14,32 +14,36 @@ export class ProfilesController {
constructor(private readonly profilesService: ProfilesService) {}
@Post()
@ApiOperation({ summary: 'Create my profile' })
create(
@CurrentUser('id') userId: string,
@Body() dto: CreateProfileDto,
) {
@ApiOperation({ summary: 'Create a new profile (one user can have many)' })
create(@CurrentUser('id') userId: string, @Body() dto: CreateProfileDto) {
return this.profilesService.create(userId, dto);
}
@Put()
@ApiOperation({ summary: 'Update my profile' })
@Get('my')
@ApiOperation({ summary: 'Get all my profiles' })
getMyProfiles(@CurrentUser('id') userId: string) {
return this.profilesService.findAllByUserId(userId);
}
@Put(':profileId')
@ApiOperation({ summary: 'Update profile by ID (must be owner)' })
update(
@CurrentUser('id') userId: string,
@Param('profileId') profileId: string,
@Body() dto: UpdateProfileDto,
) {
return this.profilesService.update(userId, dto);
return this.profilesService.update(userId, profileId, dto);
}
@Get('me')
@ApiOperation({ summary: 'Get my profile' })
getMyProfile(@CurrentUser('id') userId: string) {
return this.profilesService.findByUserId(userId);
}
@Get(':id')
@Get(':profileId')
@ApiOperation({ summary: 'Get profile by ID' })
findOne(@Param('id') id: string) {
return this.profilesService.findByProfileId(id);
findOne(@Param('profileId') profileId: string) {
return this.profilesService.findByProfileId(profileId);
}
@Delete(':profileId')
@ApiOperation({ summary: 'Delete profile (must be owner)' })
delete(@CurrentUser('id') userId: string, @Param('profileId') profileId: string) {
return this.profilesService.delete(userId, profileId);
}
}

View File

@@ -1,12 +1,7 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { eq, inArray } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service';
import { profile, profileTag, tag, media, city, cityDistrict } from '../../database/schema';
import { profile, profileMedia, profileTag, tag, city, cityDistrict } from '../../database/schema';
import { CreateProfileDto } from './dto/create-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
@@ -15,14 +10,6 @@ export class ProfilesService {
constructor(private readonly drizzleService: DrizzleService) {}
async create(userId: string, dto: CreateProfileDto) {
const existing = await this.drizzleService.db
.select({ id: profile.id })
.from(profile)
.where(eq(profile.userId, userId))
.limit(1);
if (existing.length > 0) throw new ConflictException('Profile already exists');
const [newProfile] = await this.drizzleService.db
.insert(profile)
.values({
@@ -46,40 +33,36 @@ export class ProfilesService {
return this.findByProfileId(newProfile.id);
}
async update(userId: string, dto: UpdateProfileDto) {
const [found] = await this.drizzleService.db
.select({ id: profile.id })
.from(profile)
.where(eq(profile.userId, userId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
async update(userId: string, profileId: string, dto: UpdateProfileDto) {
await this.assertOwnership(userId, profileId);
const { tagIds, ...fields } = dto;
const updateFields: any = {};
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined) updateFields[k] = v;
}
if (Object.keys(fields).length > 0) {
if (Object.keys(updateFields).length > 0) {
await this.drizzleService.db
.update(profile)
.set(fields)
.where(eq(profile.id, found.id));
.set(updateFields as any)
.where(eq(profile.id, profileId));
}
if (tagIds !== undefined) {
await this.setTags(found.id, tagIds);
await this.setTags(profileId, tagIds);
}
return this.findByProfileId(found.id);
return this.findByProfileId(profileId);
}
async findByUserId(userId: string) {
const [found] = await this.drizzleService.db
.select({ id: profile.id })
async findAllByUserId(userId: string) {
const profiles = await this.drizzleService.db
.select()
.from(profile)
.where(eq(profile.userId, userId))
.limit(1);
.where(eq(profile.userId, userId));
if (!found) throw new NotFoundException('Profile not found');
return this.findByProfileId(found.id);
return Promise.all(profiles.map((p) => this.findByProfileId(p.id)));
}
async findByProfileId(profileId: string) {
@@ -93,18 +76,43 @@ export class ProfilesService {
if (!found) throw new NotFoundException('Profile not found');
const tags = await this.drizzleService.db
.select({ id: tag.id, value: tag.value })
.from(profileTag)
.innerJoin(tag, eq(tag.id, profileTag.tagId))
.where(eq(profileTag.profileId, profileId));
const [tags, media] = await Promise.all([
this.drizzleService.db
.select({ id: tag.id, value: tag.value })
.from(profileTag)
.innerJoin(tag, eq(tag.id, profileTag.tagId))
.where(eq(profileTag.profileId, profileId)),
this.drizzleService.db
.select()
.from(profileMedia)
.where(eq(profileMedia.profileId, profileId))
.orderBy(profileMedia.sortOrder),
]);
const medias = await this.drizzleService.db
.select()
.from(media)
.where(eq(media.userId, found.profile.userId));
return {
...found.profile,
city: found.city,
district: found.city_district,
tags,
media,
};
}
return { ...found.profile, city: found.city, district: found.city_district, tags, media: medias };
async delete(userId: string, profileId: string) {
await this.assertOwnership(userId, profileId);
await this.drizzleService.db.delete(profile).where(eq(profile.id, profileId));
return { message: 'Profile deleted' };
}
private async assertOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
private async setTags(profileId: string, tagIds: string[]) {
@@ -113,9 +121,9 @@ export class ProfilesService {
.where(eq(profileTag.profileId, profileId));
if (tagIds.length > 0) {
await this.drizzleService.db.insert(profileTag).values(
tagIds.map((tagId) => ({ profileId, tagId })),
);
await this.drizzleService.db
.insert(profileTag)
.values(tagIds.map((tagId) => ({ profileId, tagId })));
}
}
}

View File

@@ -2,6 +2,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateReportDto {
@ApiProperty({ description: 'Your profile ID' })
@IsUUID()
sourceProfileId: string;
@ApiProperty()
@IsUUID()
entityId: string;

View File

@@ -16,10 +16,7 @@ export class ReportsController {
@Post()
@ApiOperation({ summary: 'Submit a report' })
create(
@CurrentUser('id') userId: string,
@Body() dto: CreateReportDto,
) {
create(@CurrentUser('id') userId: string, @Body() dto: CreateReportDto) {
return this.reportsService.create(userId, dto);
}

View File

@@ -1,23 +1,26 @@
import { Injectable } from '@nestjs/common';
import { DrizzleService } from '../../database/drizzle.service';
import { report } from '../../database/schema';
import { CreateReportDto } from './dto/create-report.dto';
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service';
import { profile, report } from '../../database/schema';
import { CreateReportDto } from './dto/create-report.dto';
@Injectable()
export class ReportsService {
constructor(private readonly drizzleService: DrizzleService) {}
async create(userId: string, dto: CreateReportDto) {
await this.assertProfileOwnership(userId, dto.sourceProfileId);
const [newReport] = await this.drizzleService.db
.insert(report)
.values({
sourceUser: userId,
sourceProfileId: dto.sourceProfileId,
entityId: dto.entityId,
entityType: dto.entityType,
description: dto.description || null,
} as any)
.returning();
return newReport;
}
@@ -25,10 +28,14 @@ export class ReportsService {
return this.drizzleService.db.select().from(report).orderBy(report.id);
}
async getByUser(userId: string) {
return this.drizzleService.db
.select()
.from(report)
.where(eq(report.sourceUser, userId));
private async assertProfileOwnership(userId: string, profileId: string) {
const [found] = await this.drizzleService.db
.select({ userId: profile.userId })
.from(profile)
.where(eq(profile.id, profileId))
.limit(1);
if (!found) throw new NotFoundException('Profile not found');
if (found.userId !== userId) throw new ForbiddenException('Profile does not belong to you');
}
}

View File

@@ -14,9 +14,9 @@ export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('me')
@ApiOperation({ summary: 'Get current user profile' })
@ApiOperation({ summary: 'Get current user with profile list' })
getMe(@CurrentUser('id') userId: string) {
return this.usersService.getMyProfile(userId);
return this.usersService.getMe(userId);
}
@Get(':id')
@@ -28,7 +28,7 @@ export class UsersController {
@Patch(':id/ban')
@Roles('admin', 'moderator')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Ban user (admin/moderator only)' })
@ApiOperation({ summary: 'Ban user' })
ban(@Param('id') id: string) {
return this.usersService.banUser(id);
}
@@ -36,7 +36,7 @@ export class UsersController {
@Patch(':id/activate')
@Roles('admin', 'moderator')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Activate user (admin/moderator only)' })
@ApiOperation({ summary: 'Activate user' })
activate(@Param('id') id: string) {
return this.usersService.activateUser(id);
}

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DrizzleService } from '../../database/drizzle.service';
import { user, profile, media, role } from '../../database/schema';
import { user, profile, role } from '../../database/schema';
@Injectable()
export class UsersService {
@@ -19,38 +19,23 @@ export class UsersService {
return rest;
}
async findByIdWithProfile(id: string) {
async getMe(userId: string) {
const [found] = await this.drizzleService.db
.select()
.from(user)
.leftJoin(profile, eq(profile.userId, user.id))
.where(eq(user.id, id))
.limit(1);
if (!found) throw new NotFoundException('User not found');
return found;
}
async getMyProfile(userId: string) {
const result = await this.drizzleService.db
.select()
.from(user)
.leftJoin(profile, eq(profile.userId, user.id))
.leftJoin(role, eq(role.id, user.roleId))
.where(eq(user.id, userId))
.limit(1);
if (!result.length) throw new NotFoundException('User not found');
const row = result[0];
const { password, ...userFields } = row.user;
return { ...userFields, profile: row.profile, role: row.role };
}
if (!found) throw new NotFoundException('User not found');
const { password, ...userFields } = found.user;
async getMediaByUserId(userId: string) {
return this.drizzleService.db
.select()
.from(media)
.where(eq(media.userId, userId));
const profiles = await this.drizzleService.db
.select({ id: profile.id, name: profile.name, gender: profile.gender })
.from(profile)
.where(eq(profile.userId, userId));
return { ...userFields, role: found.role, profiles };
}
async banUser(userId: string) {