first commit
This commit is contained in:
69
src/modules/chat/chat.controller.ts
Normal file
69
src/modules/chat/chat.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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';
|
||||
import { ChatService } from './chat.service';
|
||||
import { CreateChatDto } from './dto/create-chat.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
|
||||
@ApiTags('chat')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('chats')
|
||||
export class ChatController {
|
||||
constructor(private readonly chatService: ChatService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Open a chat for a match' })
|
||||
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);
|
||||
}
|
||||
|
||||
@Get(':chatId/messages')
|
||||
@ApiOperation({ summary: 'Get chat messages' })
|
||||
getMessages(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('chatId') chatId: string,
|
||||
@Query('page') page = 1,
|
||||
@Query('limit') limit = 50,
|
||||
) {
|
||||
return this.chatService.getChatMessages(userId, chatId, +page, +limit);
|
||||
}
|
||||
|
||||
@Post(':chatId/messages')
|
||||
@ApiOperation({ summary: 'Send a message' })
|
||||
sendMessage(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('chatId') chatId: string,
|
||||
@Body() dto: SendMessageDto,
|
||||
) {
|
||||
return this.chatService.sendMessage(userId, chatId, dto);
|
||||
}
|
||||
|
||||
@Delete(':chatId')
|
||||
@ApiOperation({ summary: 'Close a chat' })
|
||||
closeChat(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('chatId') chatId: string,
|
||||
) {
|
||||
return this.chatService.closeChat(userId, chatId);
|
||||
}
|
||||
}
|
||||
10
src/modules/chat/chat.module.ts
Normal file
10
src/modules/chat/chat.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatService } from './chat.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ChatController],
|
||||
providers: [ChatService],
|
||||
exports: [ChatService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
189
src/modules/chat/chat.service.ts
Normal file
189
src/modules/chat/chat.service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} 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 { NotificationsService } from '../../notifications/notifications.service';
|
||||
import { CreateChatDto } from './dto/create-chat.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly drizzleService: DrizzleService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async createChat(userId: string, dto: CreateChatDto) {
|
||||
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)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!foundMatch) throw new NotFoundException('Match not found');
|
||||
|
||||
const existingChat = await this.drizzleService.db
|
||||
.select()
|
||||
.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),
|
||||
),
|
||||
),
|
||||
)
|
||||
.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))
|
||||
.limit(1);
|
||||
|
||||
if (currentUser[0]?.activeChatId) {
|
||||
throw new BadRequestException(
|
||||
'You already have 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,
|
||||
status: 'active',
|
||||
} as any)
|
||||
.returning();
|
||||
|
||||
await this.drizzleService.db
|
||||
.update(user)
|
||||
.set({ activeChatId: newChat.id } as any)
|
||||
.where(or(eq(user.id, foundMatch.user1Id), eq(user.id, foundMatch.user2Id)));
|
||||
|
||||
return newChat;
|
||||
}
|
||||
|
||||
async closeChat(userId: string, chatId: string) {
|
||||
const [foundChat] = await this.drizzleService.db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(eq(chat.id, chatId))
|
||||
.limit(1);
|
||||
|
||||
if (!foundChat) throw new NotFoundException('Chat not found');
|
||||
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) {
|
||||
throw new ForbiddenException('Not a chat participant');
|
||||
}
|
||||
|
||||
await this.drizzleService.db
|
||||
.update(chat)
|
||||
.set({ status: 'closed' } as any)
|
||||
.where(eq(chat.id, chatId));
|
||||
|
||||
await this.drizzleService.db
|
||||
.update(user)
|
||||
.set({ activeChatId: null } as any)
|
||||
.where(or(eq(user.id, foundChat.profile1Id), eq(user.id, foundChat.profile2Id)));
|
||||
|
||||
return { message: 'Chat closed' };
|
||||
}
|
||||
|
||||
async getMyChats(userId: string) {
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(
|
||||
and(
|
||||
or(eq(chat.profile1Id, userId), eq(chat.profile2Id, userId)),
|
||||
eq(chat.status, 'active'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getChatMessages(userId: string, chatId: string, page = 1, limit = 50) {
|
||||
const [foundChat] = await this.drizzleService.db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(eq(chat.id, chatId))
|
||||
.limit(1);
|
||||
|
||||
if (!foundChat) throw new NotFoundException('Chat not found');
|
||||
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) {
|
||||
throw new ForbiddenException('Not a chat participant');
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(message)
|
||||
.where(eq(message.chatId, chatId))
|
||||
.orderBy(message.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async sendMessage(userId: string, chatId: string, dto: SendMessageDto) {
|
||||
const [foundChat] = await this.drizzleService.db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(eq(chat.id, chatId), eq(chat.status, 'active')))
|
||||
.limit(1);
|
||||
|
||||
if (!foundChat) throw new NotFoundException('Active chat not found');
|
||||
if (foundChat.profile1Id !== userId && foundChat.profile2Id !== userId) {
|
||||
throw new ForbiddenException('Not a chat participant');
|
||||
}
|
||||
|
||||
if (!dto.text && !dto.mediaUrl) {
|
||||
throw new BadRequestException('Message must have text or media');
|
||||
}
|
||||
|
||||
const [newMessage] = await this.drizzleService.db
|
||||
.insert(message)
|
||||
.values({
|
||||
chatId,
|
||||
userId,
|
||||
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 [recipient] = await this.drizzleService.db
|
||||
.select({ fcmToken: user.fcmToken })
|
||||
.from(user)
|
||||
.where(eq(user.id, recipientId))
|
||||
.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' },
|
||||
);
|
||||
}
|
||||
|
||||
return newMessage;
|
||||
}
|
||||
}
|
||||
8
src/modules/chat/dto/create-chat.dto.ts
Normal file
8
src/modules/chat/dto/create-chat.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateChatDto {
|
||||
@ApiProperty({ description: 'Match ID to open chat for' })
|
||||
@IsUUID()
|
||||
matchId: string;
|
||||
}
|
||||
19
src/modules/chat/dto/send-message.dto.ts
Normal file
19
src/modules/chat/dto/send-message.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class SendMessageDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
text?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mediaUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['photo', 'voice', 'video'] })
|
||||
@IsOptional()
|
||||
@IsEnum(['photo', 'voice', 'video'])
|
||||
mediaType?: 'photo' | 'voice' | 'video';
|
||||
}
|
||||
45
src/modules/cities/cities.controller.ts
Normal file
45
src/modules/cities/cities.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { CitiesService } from './cities.service';
|
||||
|
||||
@ApiTags('cities')
|
||||
@Controller('cities')
|
||||
export class CitiesController {
|
||||
constructor(private readonly citiesService: CitiesService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all cities' })
|
||||
findAll() {
|
||||
return this.citiesService.findAll();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':cityId/districts')
|
||||
@ApiOperation({ summary: 'Get districts for a city' })
|
||||
findDistricts(@Param('cityId') cityId: string) {
|
||||
return this.citiesService.findDistricts(cityId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create city (admin only)' })
|
||||
createCity(@Body() body: { name: string; lat: number; lng: number }) {
|
||||
return this.citiesService.createCity(body.name, body.lat, body.lng);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@Post(':cityId/districts')
|
||||
@ApiOperation({ summary: 'Create district (admin only)' })
|
||||
createDistrict(@Param('cityId') cityId: string, @Body() body: { name: string }) {
|
||||
return this.citiesService.createDistrict(cityId, body.name);
|
||||
}
|
||||
}
|
||||
9
src/modules/cities/cities.module.ts
Normal file
9
src/modules/cities/cities.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CitiesController } from './cities.controller';
|
||||
import { CitiesService } from './cities.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CitiesController],
|
||||
providers: [CitiesService],
|
||||
})
|
||||
export class CitiesModule {}
|
||||
37
src/modules/cities/cities.service.ts
Normal file
37
src/modules/cities/cities.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DrizzleService } from '../../database/drizzle.service';
|
||||
import { city, cityDistrict } from '../../database/schema';
|
||||
|
||||
@Injectable()
|
||||
export class CitiesService {
|
||||
constructor(private readonly drizzleService: DrizzleService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.drizzleService.db.select().from(city).orderBy(city.name);
|
||||
}
|
||||
|
||||
async findDistricts(cityId: string) {
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(cityDistrict)
|
||||
.where(eq(cityDistrict.cityId, cityId))
|
||||
.orderBy(cityDistrict.name);
|
||||
}
|
||||
|
||||
async createCity(name: string, lat: number, lng: number) {
|
||||
const [newCity] = await this.drizzleService.db
|
||||
.insert(city)
|
||||
.values({ name, lat: lat.toString(), lng: lng.toString() })
|
||||
.returning();
|
||||
return newCity;
|
||||
}
|
||||
|
||||
async createDistrict(cityId: string, name: string) {
|
||||
const [newDistrict] = await this.drizzleService.db
|
||||
.insert(cityDistrict)
|
||||
.values({ cityId, name })
|
||||
.returning();
|
||||
return newDistrict;
|
||||
}
|
||||
}
|
||||
46
src/modules/dates/dates.controller.ts
Normal file
46
src/modules/dates/dates.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, 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';
|
||||
import { DatesService } from './dates.service';
|
||||
import { CreateDateDto } from './dto/create-date.dto';
|
||||
import { UpdateDateStatusDto } from './dto/update-date-status.dto';
|
||||
|
||||
@ApiTags('dates')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('dates')
|
||||
export class DatesController {
|
||||
constructor(private readonly datesService: DatesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Propose a date/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);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ApiOperation({ summary: 'Update date status' })
|
||||
updateStatus(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateDateStatusDto,
|
||||
) {
|
||||
return this.datesService.updateStatus(userId, id, dto);
|
||||
}
|
||||
|
||||
@Get('statuses')
|
||||
@ApiOperation({ summary: 'Get available date statuses' })
|
||||
getStatuses() {
|
||||
return this.datesService.getStatuses();
|
||||
}
|
||||
}
|
||||
9
src/modules/dates/dates.module.ts
Normal file
9
src/modules/dates/dates.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatesController } from './dates.controller';
|
||||
import { DatesService } from './dates.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DatesController],
|
||||
providers: [DatesService],
|
||||
})
|
||||
export class DatesModule {}
|
||||
72
src/modules/dates/dates.service.ts
Normal file
72
src/modules/dates/dates.service.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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 { CreateDateDto } from './dto/create-date.dto';
|
||||
import { UpdateDateStatusDto } from './dto/update-date-status.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DatesService {
|
||||
constructor(private readonly drizzleService: DrizzleService) {}
|
||||
|
||||
async create(userId: string, dto: CreateDateDto) {
|
||||
let statusId = dto.statusId;
|
||||
|
||||
if (!statusId) {
|
||||
const [pending] = await this.drizzleService.db
|
||||
.select({ id: dateStatus.id })
|
||||
.from(dateStatus)
|
||||
.where(eq(dateStatus.text, 'pending'))
|
||||
.limit(1);
|
||||
statusId = pending?.id;
|
||||
}
|
||||
|
||||
const [newDate] = await this.drizzleService.db
|
||||
.insert(date)
|
||||
.values({
|
||||
user1Id: userId,
|
||||
user2Id: dto.partnerId,
|
||||
lat: dto.lat.toString(),
|
||||
lng: dto.lng.toString(),
|
||||
time: new Date(dto.time),
|
||||
statusId: statusId || null,
|
||||
} as any)
|
||||
.returning();
|
||||
|
||||
return newDate;
|
||||
}
|
||||
|
||||
async getMyDates(userId: string) {
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(date)
|
||||
.leftJoin(dateStatus, eq(dateStatus.id, date.statusId))
|
||||
.where(or(eq(date.user1Id, userId), eq(date.user2Id, userId)))
|
||||
.orderBy(date.time);
|
||||
}
|
||||
|
||||
async updateStatus(userId: string, dateId: string, dto: UpdateDateStatusDto) {
|
||||
const [found] = await this.drizzleService.db
|
||||
.select()
|
||||
.from(date)
|
||||
.where(eq(date.id, dateId))
|
||||
.limit(1);
|
||||
|
||||
if (!found) throw new NotFoundException('Date not found');
|
||||
if (found.user1Id !== userId && found.user2Id !== userId) {
|
||||
throw new ForbiddenException('Not a participant');
|
||||
}
|
||||
|
||||
const [updated] = await this.drizzleService.db
|
||||
.update(date)
|
||||
.set({ statusId: dto.statusId } as any)
|
||||
.where(eq(date.id, dateId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async getStatuses() {
|
||||
return this.drizzleService.db.select().from(dateStatus);
|
||||
}
|
||||
}
|
||||
25
src/modules/dates/dto/create-date.dto.ts
Normal file
25
src/modules/dates/dto/create-date.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateDateDto {
|
||||
@ApiProperty()
|
||||
@IsUUID()
|
||||
partnerId: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
lat: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
lng: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsDateString()
|
||||
time: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
statusId?: string;
|
||||
}
|
||||
8
src/modules/dates/dto/update-date-status.dto.ts
Normal file
8
src/modules/dates/dto/update-date-status.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateDateStatusDto {
|
||||
@ApiProperty()
|
||||
@IsUUID()
|
||||
statusId: string;
|
||||
}
|
||||
63
src/modules/feed/dto/feed-filter.dto.ts
Normal file
63
src/modules/feed/dto/feed-filter.dto.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
export class FeedFilterDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit?: number = 20;
|
||||
|
||||
@ApiPropertyOptional({ description: 'City UUID filter' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cityId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'District UUID filter' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
districtId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Search radius in km' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
radiusKm?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(18)
|
||||
ageMin?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Max(100)
|
||||
ageMax?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Search keyword in description/name' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Tag IDs to filter' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
tagIds?: string[];
|
||||
}
|
||||
23
src/modules/feed/feed.controller.ts
Normal file
23
src/modules/feed/feed.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller, Get, 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';
|
||||
import { FeedFilterDto } from './dto/feed-filter.dto';
|
||||
import { FeedService } from './feed.service';
|
||||
|
||||
@ApiTags('feed')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('feed')
|
||||
export class FeedController {
|
||||
constructor(private readonly feedService: FeedService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get filtered feed of profiles' })
|
||||
getFeed(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query() filter: FeedFilterDto,
|
||||
) {
|
||||
return this.feedService.getFeed(userId, filter);
|
||||
}
|
||||
}
|
||||
9
src/modules/feed/feed.module.ts
Normal file
9
src/modules/feed/feed.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FeedController } from './feed.controller';
|
||||
import { FeedService } from './feed.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FeedController],
|
||||
providers: [FeedService],
|
||||
})
|
||||
export class FeedModule {}
|
||||
114
src/modules/feed/feed.service.ts
Normal file
114
src/modules/feed/feed.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Injectable } 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 { 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;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const alreadyInteracted = await this.drizzleService.db
|
||||
.select({ targetUser: like.targetUser })
|
||||
.from(like)
|
||||
.where(eq(like.sourceUser, currentUserId));
|
||||
|
||||
const interactedIds = alreadyInteracted.map((r) => r.targetUser);
|
||||
|
||||
const conditions: any[] = [
|
||||
ne(profile.userId, currentUserId),
|
||||
ne(user.status, 'banned'),
|
||||
];
|
||||
|
||||
if (interactedIds.length > 0) {
|
||||
conditions.push(notInArray(profile.userId, interactedIds));
|
||||
}
|
||||
|
||||
if (cityId) conditions.push(eq(profile.cityId, cityId));
|
||||
if (districtId) conditions.push(eq(profile.districtId, districtId));
|
||||
|
||||
if (ageMin) {
|
||||
const maxBirthDate = new Date();
|
||||
maxBirthDate.setFullYear(maxBirthDate.getFullYear() - ageMin);
|
||||
conditions.push(lte(profile.birthDate, maxBirthDate.toISOString().split('T')[0]));
|
||||
}
|
||||
|
||||
if (ageMax) {
|
||||
const minBirthDate = new Date();
|
||||
minBirthDate.setFullYear(minBirthDate.getFullYear() - ageMax);
|
||||
conditions.push(gte(profile.birthDate, minBirthDate.toISOString().split('T')[0]));
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(profile.name, `%${keyword}%`),
|
||||
ilike(profile.description, `%${keyword}%`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 rows = await this.drizzleService.db
|
||||
.select({
|
||||
id: profile.id,
|
||||
userId: profile.userId,
|
||||
name: profile.name,
|
||||
birthDate: profile.birthDate,
|
||||
cityId: profile.cityId,
|
||||
districtId: profile.districtId,
|
||||
description: profile.description,
|
||||
nation: profile.nation,
|
||||
height: profile.height,
|
||||
weight: profile.weight,
|
||||
})
|
||||
.from(profile)
|
||||
.innerJoin(user, eq(user.id, profile.userId))
|
||||
.where(and(...conditions))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.orderBy(sql`RANDOM()`);
|
||||
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
|
||||
return { data: enriched, page, limit };
|
||||
}
|
||||
|
||||
private calculateAge(birthDate: string): number {
|
||||
const birth = new Date(birthDate);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const m = today.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||
return age;
|
||||
}
|
||||
}
|
||||
38
src/modules/greetings/greetings.controller.ts
Normal file
38
src/modules/greetings/greetings.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { GreetingsService } from './greetings.service';
|
||||
|
||||
@ApiTags('greetings')
|
||||
@Controller('greetings')
|
||||
export class GreetingsController {
|
||||
constructor(private readonly greetingsService: GreetingsService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all greeting phrases' })
|
||||
findAll() {
|
||||
return this.greetingsService.findAll();
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Add greeting phrase (admin only)' })
|
||||
create(@Body('text') text: string) {
|
||||
return this.greetingsService.create(text);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete greeting phrase (admin only)' })
|
||||
delete(@Param('id') id: string) {
|
||||
return this.greetingsService.delete(id);
|
||||
}
|
||||
}
|
||||
9
src/modules/greetings/greetings.module.ts
Normal file
9
src/modules/greetings/greetings.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GreetingsController } from './greetings.controller';
|
||||
import { GreetingsService } from './greetings.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GreetingsController],
|
||||
providers: [GreetingsService],
|
||||
})
|
||||
export class GreetingsModule {}
|
||||
26
src/modules/greetings/greetings.service.ts
Normal file
26
src/modules/greetings/greetings.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DrizzleService } from '../../database/drizzle.service';
|
||||
import { greetings } from '../../database/schema';
|
||||
|
||||
@Injectable()
|
||||
export class GreetingsService {
|
||||
constructor(private readonly drizzleService: DrizzleService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.drizzleService.db.select().from(greetings);
|
||||
}
|
||||
|
||||
async create(text: string) {
|
||||
const [newGreeting] = await this.drizzleService.db
|
||||
.insert(greetings)
|
||||
.values({ text })
|
||||
.returning();
|
||||
return newGreeting;
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
await this.drizzleService.db.delete(greetings).where(eq(greetings.id, id));
|
||||
return { message: 'Greeting deleted' };
|
||||
}
|
||||
}
|
||||
12
src/modules/likes/dto/create-like.dto.ts
Normal file
12
src/modules/likes/dto/create-like.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateLikeDto {
|
||||
@ApiProperty()
|
||||
@IsUUID()
|
||||
targetUserId: string;
|
||||
|
||||
@ApiProperty({ enum: ['like', 'dislike'] })
|
||||
@IsEnum(['like', 'dislike'])
|
||||
type: 'like' | 'dislike';
|
||||
}
|
||||
29
src/modules/likes/likes.controller.ts
Normal file
29
src/modules/likes/likes.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Get, Post, 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';
|
||||
import { CreateLikeDto } from './dto/create-like.dto';
|
||||
import { LikesService } from './likes.service';
|
||||
|
||||
@ApiTags('likes')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('likes')
|
||||
export class LikesController {
|
||||
constructor(private readonly likesService: LikesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Like or dislike a user' })
|
||||
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);
|
||||
}
|
||||
}
|
||||
9
src/modules/likes/likes.module.ts
Normal file
9
src/modules/likes/likes.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LikesController } from './likes.controller';
|
||||
import { LikesService } from './likes.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LikesController],
|
||||
providers: [LikesService],
|
||||
})
|
||||
export class LikesModule {}
|
||||
147
src/modules/likes/likes.service.ts
Normal file
147
src/modules/likes/likes.service.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import { DrizzleService } from '../../database/drizzle.service';
|
||||
import { like, match, 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()
|
||||
export class LikesService {
|
||||
constructor(
|
||||
private readonly drizzleService: DrizzleService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async createLike(sourceUserId: string, dto: CreateLikeDto) {
|
||||
if (sourceUserId === dto.targetUserId) {
|
||||
throw new BadRequestException('Cannot like yourself');
|
||||
}
|
||||
|
||||
const maxMatches = this.configService.get<number>('app.maxMatchesBeforePause');
|
||||
const activeMatchesCount = await this.getActiveMatchesCount(sourceUserId);
|
||||
if (activeMatchesCount >= maxMatches) {
|
||||
throw new BadRequestException(
|
||||
`You have ${activeMatchesCount} matches. Resolve them before searching for new ones.`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.drizzleService.db
|
||||
.select()
|
||||
.from(like)
|
||||
.where(
|
||||
and(
|
||||
eq(like.sourceUser, sourceUserId),
|
||||
eq(like.targetUser, dto.targetUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new BadRequestException('Already reacted to this user');
|
||||
}
|
||||
|
||||
const [newLike] = await this.drizzleService.db
|
||||
.insert(like)
|
||||
.values({
|
||||
sourceUser: sourceUserId,
|
||||
targetUser: dto.targetUserId,
|
||||
type: dto.type,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (dto.type === 'like') {
|
||||
return this.checkAndCreateMatch(sourceUserId, dto.targetUserId, newLike);
|
||||
}
|
||||
|
||||
return { like: newLike, match: null };
|
||||
}
|
||||
|
||||
private async checkAndCreateMatch(userId1: string, userId2: string, newLike: any) {
|
||||
const reverseLike = await this.drizzleService.db
|
||||
.select()
|
||||
.from(like)
|
||||
.where(
|
||||
and(
|
||||
eq(like.sourceUser, userId2),
|
||||
eq(like.targetUser, userId1),
|
||||
eq(like.type, 'like'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
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)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingMatch.length > 0) {
|
||||
return { like: newLike, match: existingMatch[0] };
|
||||
}
|
||||
|
||||
const [newMatch] = await this.drizzleService.db
|
||||
.insert(match)
|
||||
.values({ user1Id: userId1, user2Id: userId2 })
|
||||
.returning();
|
||||
|
||||
await this.notifyMatch(userId1, userId2, newMatch.id);
|
||||
|
||||
return { like: newLike, match: newMatch };
|
||||
}
|
||||
|
||||
private async getActiveMatchesCount(userId: 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)),
|
||||
);
|
||||
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)));
|
||||
|
||||
for (const u of users) {
|
||||
if (u.fcmToken) {
|
||||
await this.notificationsService.sendPushNotification(
|
||||
u.fcmToken,
|
||||
'New Match!',
|
||||
'You have a new match! Start chatting now.',
|
||||
{ matchId, type: 'match' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.redisService.publish('match:created', JSON.stringify({ matchId, userId1, userId2 }));
|
||||
}
|
||||
|
||||
async getMyMatches(userId: string) {
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(match)
|
||||
.where(
|
||||
or(eq(match.user1Id, userId), eq(match.user2Id, userId)),
|
||||
)
|
||||
.orderBy(match.createdAt);
|
||||
}
|
||||
}
|
||||
55
src/modules/media/media.controller.ts
Normal file
55
src/modules/media/media.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
@ApiTags('media')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('media')
|
||||
export class MediaController {
|
||||
constructor(private readonly mediaService: MediaService) {}
|
||||
|
||||
@Post('upload')
|
||||
@ApiOperation({ summary: 'Upload photo or video' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async upload(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Req() req: FastifyRequest,
|
||||
@Query('type') type: 'photo' | 'video' = 'photo',
|
||||
) {
|
||||
const data = await (req as any).file();
|
||||
if (!data) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
const buffer = await data.toBuffer();
|
||||
return this.mediaService.uploadMedia(
|
||||
userId,
|
||||
{ buffer, originalname: data.filename, mimetype: data.mimetype },
|
||||
type,
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get my media' })
|
||||
getMyMedia(@CurrentUser('id') userId: string) {
|
||||
return this.mediaService.getByUserId(userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete media' })
|
||||
deleteMedia(@CurrentUser('id') userId: string, @Param('id') id: string) {
|
||||
return this.mediaService.deleteMedia(userId, id);
|
||||
}
|
||||
}
|
||||
10
src/modules/media/media.module.ts
Normal file
10
src/modules/media/media.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MediaController } from './media.controller';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
60
src/modules/media/media.service.ts
Normal file
60
src/modules/media/media.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DrizzleService } from '../../database/drizzle.service';
|
||||
import { media } from '../../database/schema';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
constructor(
|
||||
private readonly drizzleService: DrizzleService,
|
||||
private readonly storageService: StorageService,
|
||||
) {}
|
||||
|
||||
async uploadMedia(
|
||||
userId: string,
|
||||
file: { buffer: Buffer; originalname: string; mimetype: string },
|
||||
type: 'photo' | 'video',
|
||||
) {
|
||||
const folder = type === 'photo' ? 'photos' : 'videos';
|
||||
const objectName = await this.storageService.uploadFile(
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
folder,
|
||||
);
|
||||
const publicUrl = this.storageService.getPublicUrl(objectName);
|
||||
|
||||
const [newMedia] = await this.drizzleService.db
|
||||
.insert(media)
|
||||
.values({ userId, path: publicUrl, type })
|
||||
.returning();
|
||||
|
||||
return newMedia;
|
||||
}
|
||||
|
||||
async getByUserId(userId: string) {
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq(media.userId, userId));
|
||||
}
|
||||
|
||||
async deleteMedia(userId: string, mediaId: string) {
|
||||
const [found] = await this.drizzleService.db
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq(media.id, mediaId))
|
||||
.limit(1);
|
||||
|
||||
if (!found || found.userId !== userId) {
|
||||
throw new NotFoundException('Media not found');
|
||||
}
|
||||
|
||||
const objectName = found.path.split('/').slice(-2).join('/');
|
||||
await this.storageService.deleteFile(objectName).catch(() => {});
|
||||
|
||||
await this.drizzleService.db.delete(media).where(eq(media.id, mediaId));
|
||||
return { message: 'Media deleted' };
|
||||
}
|
||||
}
|
||||
53
src/modules/profiles/dto/create-profile.dto.ts
Normal file
53
src/modules/profiles/dto/create-profile.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateProfileDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: '1995-06-15' })
|
||||
@IsDateString()
|
||||
birthDate: string;
|
||||
|
||||
@ApiProperty({ enum: ['male', 'female'] })
|
||||
@IsEnum(['male', 'female'])
|
||||
gender: 'male' | 'female';
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cityId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
districtId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nation?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
height?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
weight?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
tagIds?: string[];
|
||||
}
|
||||
4
src/modules/profiles/dto/update-profile.dto.ts
Normal file
4
src/modules/profiles/dto/update-profile.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateProfileDto } from './create-profile.dto';
|
||||
|
||||
export class UpdateProfileDto extends PartialType(CreateProfileDto) {}
|
||||
45
src/modules/profiles/profiles.controller.ts
Normal file
45
src/modules/profiles/profiles.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, 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';
|
||||
import { CreateProfileDto } from './dto/create-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfilesService } from './profiles.service';
|
||||
|
||||
@ApiTags('profiles')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('profiles')
|
||||
export class ProfilesController {
|
||||
constructor(private readonly profilesService: ProfilesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create my profile' })
|
||||
create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: CreateProfileDto,
|
||||
) {
|
||||
return this.profilesService.create(userId, dto);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOperation({ summary: 'Update my profile' })
|
||||
update(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
) {
|
||||
return this.profilesService.update(userId, dto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Get my profile' })
|
||||
getMyProfile(@CurrentUser('id') userId: string) {
|
||||
return this.profilesService.findByUserId(userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get profile by ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.profilesService.findByProfileId(id);
|
||||
}
|
||||
}
|
||||
10
src/modules/profiles/profiles.module.ts
Normal file
10
src/modules/profiles/profiles.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProfilesController } from './profiles.controller';
|
||||
import { ProfilesService } from './profiles.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ProfilesController],
|
||||
providers: [ProfilesService],
|
||||
exports: [ProfilesService],
|
||||
})
|
||||
export class ProfilesModule {}
|
||||
121
src/modules/profiles/profiles.service.ts
Normal file
121
src/modules/profiles/profiles.service.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
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 { CreateProfileDto } from './dto/create-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
|
||||
@Injectable()
|
||||
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({
|
||||
userId,
|
||||
name: dto.name,
|
||||
birthDate: dto.birthDate,
|
||||
gender: dto.gender,
|
||||
cityId: dto.cityId || null,
|
||||
districtId: dto.districtId || null,
|
||||
description: dto.description || null,
|
||||
nation: dto.nation || null,
|
||||
height: dto.height || null,
|
||||
weight: dto.weight || null,
|
||||
} as any)
|
||||
.returning();
|
||||
|
||||
if (dto.tagIds?.length) {
|
||||
await this.setTags(newProfile.id, dto.tagIds);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
const { tagIds, ...fields } = dto;
|
||||
|
||||
if (Object.keys(fields).length > 0) {
|
||||
await this.drizzleService.db
|
||||
.update(profile)
|
||||
.set(fields)
|
||||
.where(eq(profile.id, found.id));
|
||||
}
|
||||
|
||||
if (tagIds !== undefined) {
|
||||
await this.setTags(found.id, tagIds);
|
||||
}
|
||||
|
||||
return this.findByProfileId(found.id);
|
||||
}
|
||||
|
||||
async findByUserId(userId: string) {
|
||||
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');
|
||||
return this.findByProfileId(found.id);
|
||||
}
|
||||
|
||||
async findByProfileId(profileId: string) {
|
||||
const [found] = await this.drizzleService.db
|
||||
.select()
|
||||
.from(profile)
|
||||
.leftJoin(city, eq(city.id, profile.cityId))
|
||||
.leftJoin(cityDistrict, eq(cityDistrict.id, profile.districtId))
|
||||
.where(eq(profile.id, profileId))
|
||||
.limit(1);
|
||||
|
||||
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 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: medias };
|
||||
}
|
||||
|
||||
private async setTags(profileId: string, tagIds: string[]) {
|
||||
await this.drizzleService.db
|
||||
.delete(profileTag)
|
||||
.where(eq(profileTag.profileId, profileId));
|
||||
|
||||
if (tagIds.length > 0) {
|
||||
await this.drizzleService.db.insert(profileTag).values(
|
||||
tagIds.map((tagId) => ({ profileId, tagId })),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/modules/reports/dto/create-report.dto.ts
Normal file
17
src/modules/reports/dto/create-report.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateReportDto {
|
||||
@ApiProperty()
|
||||
@IsUUID()
|
||||
entityId: string;
|
||||
|
||||
@ApiProperty({ enum: ['profile', 'message'] })
|
||||
@IsEnum(['profile', 'message'])
|
||||
entityType: 'profile' | 'message';
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
33
src/modules/reports/reports.controller.ts
Normal file
33
src/modules/reports/reports.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { CreateReportDto } from './dto/create-report.dto';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@ApiTags('reports')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('reports')
|
||||
export class ReportsController {
|
||||
constructor(private readonly reportsService: ReportsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Submit a report' })
|
||||
create(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: CreateReportDto,
|
||||
) {
|
||||
return this.reportsService.create(userId, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles('admin', 'moderator')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Get all reports (admin/moderator)' })
|
||||
getAll() {
|
||||
return this.reportsService.getAll();
|
||||
}
|
||||
}
|
||||
9
src/modules/reports/reports.module.ts
Normal file
9
src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
34
src/modules/reports/reports.service.ts
Normal file
34
src/modules/reports/reports.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DrizzleService } from '../../database/drizzle.service';
|
||||
import { report } from '../../database/schema';
|
||||
import { CreateReportDto } from './dto/create-report.dto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private readonly drizzleService: DrizzleService) {}
|
||||
|
||||
async create(userId: string, dto: CreateReportDto) {
|
||||
const [newReport] = await this.drizzleService.db
|
||||
.insert(report)
|
||||
.values({
|
||||
sourceUser: userId,
|
||||
entityId: dto.entityId,
|
||||
entityType: dto.entityType,
|
||||
description: dto.description || null,
|
||||
} as any)
|
||||
.returning();
|
||||
return newReport;
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
38
src/modules/tags/tags.controller.ts
Normal file
38
src/modules/tags/tags.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
@ApiTags('tags')
|
||||
@Controller('tags')
|
||||
export class TagsController {
|
||||
constructor(private readonly tagsService: TagsService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all tags' })
|
||||
findAll() {
|
||||
return this.tagsService.findAll();
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create tag (admin only)' })
|
||||
create(@Body('value') value: string) {
|
||||
return this.tagsService.create(value);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete tag (admin only)' })
|
||||
delete(@Param('id') id: string) {
|
||||
return this.tagsService.delete(id);
|
||||
}
|
||||
}
|
||||
9
src/modules/tags/tags.module.ts
Normal file
9
src/modules/tags/tags.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagsController } from './tags.controller';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService],
|
||||
})
|
||||
export class TagsModule {}
|
||||
34
src/modules/tags/tags.service.ts
Normal file
34
src/modules/tags/tags.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DrizzleService } from '../../database/drizzle.service';
|
||||
import { tag } from '../../database/schema';
|
||||
|
||||
@Injectable()
|
||||
export class TagsService {
|
||||
constructor(private readonly drizzleService: DrizzleService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.drizzleService.db.select().from(tag).orderBy(tag.value);
|
||||
}
|
||||
|
||||
async create(value: string) {
|
||||
const existing = await this.drizzleService.db
|
||||
.select()
|
||||
.from(tag)
|
||||
.where(eq(tag.value, value))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) throw new ConflictException('Tag already exists');
|
||||
|
||||
const [newTag] = await this.drizzleService.db
|
||||
.insert(tag)
|
||||
.values({ value })
|
||||
.returning();
|
||||
return newTag;
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
await this.drizzleService.db.delete(tag).where(eq(tag.id, id));
|
||||
return { message: 'Tag deleted' };
|
||||
}
|
||||
}
|
||||
9
src/modules/users/dto/update-user.dto.ts
Normal file
9
src/modules/users/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fcmToken?: string;
|
||||
}
|
||||
43
src/modules/users/users.controller.ts
Normal file
43
src/modules/users/users.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@ApiTags('users')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
getMe(@CurrentUser('id') userId: string) {
|
||||
return this.usersService.getMyProfile(userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get user by ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.usersService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id/ban')
|
||||
@Roles('admin', 'moderator')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Ban user (admin/moderator only)' })
|
||||
ban(@Param('id') id: string) {
|
||||
return this.usersService.banUser(id);
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@Roles('admin', 'moderator')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Activate user (admin/moderator only)' })
|
||||
activate(@Param('id') id: string) {
|
||||
return this.usersService.activateUser(id);
|
||||
}
|
||||
}
|
||||
10
src/modules/users/users.module.ts
Normal file
10
src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
71
src/modules/users/users.service.ts
Normal file
71
src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly drizzleService: DrizzleService) {}
|
||||
|
||||
async findById(id: string) {
|
||||
const [found] = await this.drizzleService.db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!found) throw new NotFoundException('User not found');
|
||||
const { password, ...rest } = found;
|
||||
return rest;
|
||||
}
|
||||
|
||||
async findByIdWithProfile(id: 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 };
|
||||
}
|
||||
|
||||
async getMediaByUserId(userId: string) {
|
||||
return this.drizzleService.db
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq(media.userId, userId));
|
||||
}
|
||||
|
||||
async banUser(userId: string) {
|
||||
await this.drizzleService.db
|
||||
.update(user)
|
||||
.set({ status: 'banned' } as any)
|
||||
.where(eq(user.id, userId));
|
||||
return { message: 'User banned' };
|
||||
}
|
||||
|
||||
async activateUser(userId: string) {
|
||||
await this.drizzleService.db
|
||||
.update(user)
|
||||
.set({ status: 'active' } as any)
|
||||
.where(eq(user.id, userId));
|
||||
return { message: 'User activated' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user