Initial commit: Prism messenger with Expo + NestJS + GraphQL + PostgreSQL
This commit is contained in:
45
backend/src/app.module.ts
Normal file
45
backend/src/app.module.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { join } from 'path';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { ConversationsModule } from './modules/conversations/conversations.module';
|
||||
import { MessagesModule } from './modules/messages/messages.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
|
||||
sortSchema: true,
|
||||
subscriptions: {
|
||||
'graphql-ws': true,
|
||||
},
|
||||
context: ({ req }) => ({ req }),
|
||||
}),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
|
||||
username: process.env.DATABASE_USERNAME || 'postgres',
|
||||
password: process.env.DATABASE_PASSWORD || 'postgres',
|
||||
database: process.env.DATABASE_NAME || 'prism_messenger',
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
synchronize: true, // Только для разработки
|
||||
logging: true,
|
||||
}),
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
ConversationsModule,
|
||||
MessagesModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule {}
|
15
backend/src/config/typeorm.config.ts
Normal file
15
backend/src/config/typeorm.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export const getTypeOrmConfig = (configService: ConfigService): TypeOrmModuleOptions => ({
|
||||
type: 'postgres',
|
||||
host: configService.get('DATABASE_HOST') || 'localhost',
|
||||
port: configService.get('DATABASE_PORT') || 5432,
|
||||
username: configService.get('DATABASE_USERNAME') || 'postgres',
|
||||
password: configService.get('DATABASE_PASSWORD') || 'postgres',
|
||||
database: configService.get('DATABASE_NAME') || 'prism_messenger',
|
||||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
|
||||
synchronize: configService.get('NODE_ENV') !== 'production',
|
||||
logging: configService.get('NODE_ENV') !== 'production',
|
||||
});
|
8
backend/src/main.ts
Normal file
8
backend/src/main.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
21
backend/src/modules/auth/auth.module.ts
Normal file
21
backend/src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'secret',
|
||||
signOptions: { expiresIn: process.env.JWT_EXPIRATION || '7d' },
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, AuthResolver, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
34
backend/src/modules/auth/auth.resolver.ts
Normal file
34
backend/src/modules/auth/auth.resolver.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Resolver, Mutation, Args, ObjectType, Field } from '@nestjs/graphql';
|
||||
import { AuthService } from './auth.service';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
@ObjectType()
|
||||
class AuthResponse {
|
||||
@Field()
|
||||
access_token: string;
|
||||
|
||||
@Field(() => User)
|
||||
user: User;
|
||||
}
|
||||
|
||||
@Resolver()
|
||||
export class AuthResolver {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Mutation(() => AuthResponse)
|
||||
async login(
|
||||
@Args('username') username: string,
|
||||
@Args('password') password: string,
|
||||
) {
|
||||
return this.authService.login(username, password);
|
||||
}
|
||||
|
||||
@Mutation(() => AuthResponse)
|
||||
async register(
|
||||
@Args('username') username: string,
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string,
|
||||
) {
|
||||
return this.authService.register(username, email, password);
|
||||
}
|
||||
}
|
43
backend/src/modules/auth/auth.service.ts
Normal file
43
backend/src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async validateUser(username: string, password: string): Promise<User | null> {
|
||||
const user = await this.usersService.findByUsername(username);
|
||||
if (user && await bcrypt.compare(password, user.password)) {
|
||||
return user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const user = await this.validateUser(username, password);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Неверный логин или пароль');
|
||||
}
|
||||
|
||||
const payload = { username: user.username, sub: user.id };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
async register(username: string, email: string, password: string) {
|
||||
const user = await this.usersService.create(username, email, password);
|
||||
const payload = { username: user.username, sub: user.id };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user,
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, context: ExecutionContext) => {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
return ctx.getContext().req.user;
|
||||
},
|
||||
);
|
11
backend/src/modules/auth/guards/gql-auth.guard.ts
Normal file
11
backend/src/modules/auth/guards/gql-auth.guard.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
@Injectable()
|
||||
export class GqlAuthGuard extends AuthGuard('jwt') {
|
||||
getRequest(context: ExecutionContext) {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
return ctx.getContext().req;
|
||||
}
|
||||
}
|
20
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
20
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private usersService: UsersService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
const user = await this.usersService.findOne(payload.sub);
|
||||
return user;
|
||||
}
|
||||
}
|
12
backend/src/modules/conversations/conversations.module.ts
Normal file
12
backend/src/modules/conversations/conversations.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConversationsService } from './conversations.service';
|
||||
import { ConversationsResolver } from './conversations.resolver';
|
||||
import { Conversation } from './entities/conversation.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Conversation])],
|
||||
providers: [ConversationsResolver, ConversationsService],
|
||||
exports: [ConversationsService],
|
||||
})
|
||||
export class ConversationsModule {}
|
47
backend/src/modules/conversations/conversations.resolver.ts
Normal file
47
backend/src/modules/conversations/conversations.resolver.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Resolver, Query, Mutation, Args, ID } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { ConversationsService } from './conversations.service';
|
||||
import { Conversation } from './entities/conversation.entity';
|
||||
import { GqlAuthGuard } from '../auth/guards/gql-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
@Resolver(() => Conversation)
|
||||
@UseGuards(GqlAuthGuard)
|
||||
export class ConversationsResolver {
|
||||
constructor(private readonly conversationsService: ConversationsService) {}
|
||||
|
||||
@Query(() => [Conversation], { name: 'conversations' })
|
||||
findAll(@CurrentUser() user: User) {
|
||||
return this.conversationsService.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
@Query(() => Conversation, { name: 'conversation' })
|
||||
findOne(
|
||||
@Args('id', { type: () => ID }) id: string,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
return this.conversationsService.findOne(id, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => Conversation)
|
||||
createConversation(
|
||||
@CurrentUser() user: User,
|
||||
@Args('participantIds', { type: () => [ID] }) participantIds: string[],
|
||||
@Args('name', { nullable: true }) name?: string,
|
||||
) {
|
||||
const isGroup = participantIds.length > 1;
|
||||
// Добавляем текущего пользователя к участникам
|
||||
const allParticipantIds = [...participantIds, user.id];
|
||||
const participants = allParticipantIds.map(id => ({ id } as User));
|
||||
return this.conversationsService.create(participants, name, isGroup);
|
||||
}
|
||||
|
||||
@Mutation(() => Conversation)
|
||||
createPrivateConversation(
|
||||
@CurrentUser() user: User,
|
||||
@Args('recipientId', { type: () => ID }) recipientId: string,
|
||||
) {
|
||||
return this.conversationsService.findOrCreatePrivate(user.id, recipientId);
|
||||
}
|
||||
}
|
84
backend/src/modules/conversations/conversations.service.ts
Normal file
84
backend/src/modules/conversations/conversations.service.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Conversation } from './entities/conversation.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ConversationsService {
|
||||
constructor(
|
||||
@InjectRepository(Conversation)
|
||||
private conversationsRepository: Repository<Conversation>,
|
||||
) {}
|
||||
|
||||
async create(participants: User[], name?: string, isGroup = false): Promise<Conversation> {
|
||||
const conversation = this.conversationsRepository.create({
|
||||
participants,
|
||||
name,
|
||||
isGroup,
|
||||
});
|
||||
return this.conversationsRepository.save(conversation);
|
||||
}
|
||||
|
||||
async findAllForUser(userId: string): Promise<Conversation[]> {
|
||||
return this.conversationsRepository
|
||||
.createQueryBuilder('conversation')
|
||||
.leftJoinAndSelect('conversation.participants', 'participants')
|
||||
.leftJoinAndSelect('conversation.messages', 'messages')
|
||||
.leftJoin('conversation.participants', 'user')
|
||||
.where('user.id = :userId', { userId })
|
||||
.orderBy('messages.createdAt', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findOne(id: string, userId: string): Promise<Conversation> {
|
||||
const conversation = await this.conversationsRepository
|
||||
.createQueryBuilder('conversation')
|
||||
.leftJoinAndSelect('conversation.participants', 'participants')
|
||||
.leftJoinAndSelect('conversation.messages', 'messages')
|
||||
.leftJoinAndSelect('messages.sender', 'sender')
|
||||
.where('conversation.id = :id', { id })
|
||||
.orderBy('messages.createdAt', 'ASC')
|
||||
.getOne();
|
||||
|
||||
if (!conversation) {
|
||||
throw new NotFoundException('Беседа не найдена');
|
||||
}
|
||||
|
||||
const isParticipant = conversation.participants.some(p => p.id === userId);
|
||||
if (!isParticipant) {
|
||||
throw new ForbiddenException('Вы не являетесь участником этой беседы');
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async findOrCreatePrivate(user1Id: string, user2Id: string): Promise<Conversation> {
|
||||
const existingConversation = await this.conversationsRepository
|
||||
.createQueryBuilder('conversation')
|
||||
.leftJoin('conversation.participants', 'p1')
|
||||
.leftJoin('conversation.participants', 'p2')
|
||||
.where('conversation.isGroup = false')
|
||||
.andWhere('p1.id = :user1Id', { user1Id })
|
||||
.andWhere('p2.id = :user2Id', { user2Id })
|
||||
.getOne();
|
||||
|
||||
if (existingConversation) {
|
||||
return existingConversation;
|
||||
}
|
||||
|
||||
// Создаем новую приватную беседу
|
||||
return this.create([], undefined, false);
|
||||
}
|
||||
|
||||
async addParticipant(conversationId: string, userId: string, participantId: string): Promise<Conversation> {
|
||||
const conversation = await this.findOne(conversationId, userId);
|
||||
|
||||
if (!conversation.isGroup) {
|
||||
throw new ForbiddenException('Нельзя добавлять участников в приватные беседы');
|
||||
}
|
||||
|
||||
// Добавляем участника
|
||||
return conversation;
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import { ObjectType, Field, ID } from '@nestjs/graphql';
|
||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToMany, JoinTable, OneToMany } from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Message } from '../../messages/entities/message.entity';
|
||||
|
||||
@ObjectType()
|
||||
@Entity('conversations')
|
||||
export class Conversation {
|
||||
@Field(() => ID)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true })
|
||||
name?: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ default: false })
|
||||
isGroup: boolean;
|
||||
|
||||
@Field(() => [User])
|
||||
@ManyToMany(() => User)
|
||||
@JoinTable({
|
||||
name: 'conversation_participants',
|
||||
joinColumn: { name: 'conversationId', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'userId', referencedColumnName: 'id' },
|
||||
})
|
||||
participants: User[];
|
||||
|
||||
@Field(() => [Message])
|
||||
@OneToMany(() => Message, message => message.conversation)
|
||||
messages: Message[];
|
||||
|
||||
@Field(() => Message, { nullable: true })
|
||||
lastMessage?: Message;
|
||||
|
||||
@Field(() => Date)
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
44
backend/src/modules/messages/entities/message.entity.ts
Normal file
44
backend/src/modules/messages/entities/message.entity.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { ObjectType, Field, ID } from '@nestjs/graphql';
|
||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Conversation } from '../../conversations/entities/conversation.entity';
|
||||
|
||||
@ObjectType()
|
||||
@Entity('messages')
|
||||
export class Message {
|
||||
@Field(() => ID)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Field(() => User)
|
||||
@ManyToOne(() => User, user => user.messages)
|
||||
sender: User;
|
||||
|
||||
@Field(() => Conversation)
|
||||
@ManyToOne(() => Conversation, conversation => conversation.messages)
|
||||
conversation: Conversation;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ default: false })
|
||||
isRead: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ default: false })
|
||||
isEdited: boolean;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
@Column({ nullable: true })
|
||||
editedAt?: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
16
backend/src/modules/messages/messages.module.ts
Normal file
16
backend/src/modules/messages/messages.module.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { MessagesResolver } from './messages.resolver';
|
||||
import { Message } from './entities/message.entity';
|
||||
import { ConversationsModule } from '../conversations/conversations.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Message]),
|
||||
ConversationsModule,
|
||||
],
|
||||
providers: [MessagesResolver, MessagesService],
|
||||
exports: [MessagesService],
|
||||
})
|
||||
export class MessagesModule {}
|
72
backend/src/modules/messages/messages.resolver.ts
Normal file
72
backend/src/modules/messages/messages.resolver.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { Resolver, Query, Mutation, Args, ID, Subscription } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { Message } from './entities/message.entity';
|
||||
import { GqlAuthGuard } from '../auth/guards/gql-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
const pubSub = new PubSub();
|
||||
|
||||
@Resolver(() => Message)
|
||||
@UseGuards(GqlAuthGuard)
|
||||
export class MessagesResolver {
|
||||
constructor(private readonly messagesService: MessagesService) {}
|
||||
|
||||
@Query(() => [Message], { name: 'messages' })
|
||||
findAll(
|
||||
@Args('conversationId', { type: () => ID }) conversationId: string,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
return this.messagesService.findAllInConversation(conversationId, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => Message)
|
||||
async sendMessage(
|
||||
@CurrentUser() user: User,
|
||||
@Args('conversationId', { type: () => ID }) conversationId: string,
|
||||
@Args('content') content: string,
|
||||
) {
|
||||
const message = await this.messagesService.create(conversationId, user.id, content);
|
||||
|
||||
// Публикуем событие для подписчиков
|
||||
pubSub.publish('messageAdded', { messageAdded: message });
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@Mutation(() => Message)
|
||||
updateMessage(
|
||||
@CurrentUser() user: User,
|
||||
@Args('messageId', { type: () => ID }) messageId: string,
|
||||
@Args('content') content: string,
|
||||
) {
|
||||
return this.messagesService.update(messageId, user.id, content);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
deleteMessage(
|
||||
@CurrentUser() user: User,
|
||||
@Args('messageId', { type: () => ID }) messageId: string,
|
||||
) {
|
||||
return this.messagesService.delete(messageId, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => Message)
|
||||
markMessageAsRead(
|
||||
@CurrentUser() user: User,
|
||||
@Args('messageId', { type: () => ID }) messageId: string,
|
||||
) {
|
||||
return this.messagesService.markAsRead(messageId, user.id);
|
||||
}
|
||||
|
||||
@Subscription(() => Message, {
|
||||
filter: (payload, variables) => {
|
||||
return payload.messageAdded.conversation.id === variables.conversationId;
|
||||
},
|
||||
})
|
||||
messageAdded(@Args('conversationId', { type: () => ID }) conversationId: string) {
|
||||
return pubSub.asyncIterator('messageAdded');
|
||||
}
|
||||
}
|
99
backend/src/modules/messages/messages.service.ts
Normal file
99
backend/src/modules/messages/messages.service.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Message } from './entities/message.entity';
|
||||
import { ConversationsService } from '../conversations/conversations.service';
|
||||
|
||||
@Injectable()
|
||||
export class MessagesService {
|
||||
constructor(
|
||||
@InjectRepository(Message)
|
||||
private messagesRepository: Repository<Message>,
|
||||
private conversationsService: ConversationsService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
conversationId: string,
|
||||
senderId: string,
|
||||
content: string,
|
||||
): Promise<Message> {
|
||||
const conversation = await this.conversationsService.findOne(conversationId, senderId);
|
||||
|
||||
const message = this.messagesRepository.create({
|
||||
content,
|
||||
sender: { id: senderId },
|
||||
conversation: { id: conversationId },
|
||||
});
|
||||
|
||||
return this.messagesRepository.save(message);
|
||||
}
|
||||
|
||||
async findAllInConversation(conversationId: string, userId: string): Promise<Message[]> {
|
||||
// Проверяем, что пользователь участник беседы
|
||||
await this.conversationsService.findOne(conversationId, userId);
|
||||
|
||||
return this.messagesRepository.find({
|
||||
where: { conversation: { id: conversationId } },
|
||||
relations: ['sender'],
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async update(messageId: string, userId: string, content: string): Promise<Message> {
|
||||
const message = await this.messagesRepository.findOne({
|
||||
where: { id: messageId },
|
||||
relations: ['sender'],
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
throw new NotFoundException('Сообщение не найдено');
|
||||
}
|
||||
|
||||
if (message.sender.id !== userId) {
|
||||
throw new ForbiddenException('Вы можете редактировать только свои сообщения');
|
||||
}
|
||||
|
||||
message.content = content;
|
||||
message.isEdited = true;
|
||||
message.editedAt = new Date();
|
||||
|
||||
return this.messagesRepository.save(message);
|
||||
}
|
||||
|
||||
async markAsRead(messageId: string, userId: string): Promise<Message> {
|
||||
const message = await this.messagesRepository.findOne({
|
||||
where: { id: messageId },
|
||||
relations: ['conversation', 'conversation.participants'],
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
throw new NotFoundException('Сообщение не найдено');
|
||||
}
|
||||
|
||||
const isParticipant = message.conversation.participants.some(p => p.id === userId);
|
||||
if (!isParticipant) {
|
||||
throw new ForbiddenException('Вы не являетесь участником этой беседы');
|
||||
}
|
||||
|
||||
message.isRead = true;
|
||||
return this.messagesRepository.save(message);
|
||||
}
|
||||
|
||||
async delete(messageId: string, userId: string): Promise<boolean> {
|
||||
const message = await this.messagesRepository.findOne({
|
||||
where: { id: messageId },
|
||||
relations: ['sender'],
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
throw new NotFoundException('Сообщение не найдено');
|
||||
}
|
||||
|
||||
if (message.sender.id !== userId) {
|
||||
throw new ForbiddenException('Вы можете удалять только свои сообщения');
|
||||
}
|
||||
|
||||
await this.messagesRepository.remove(message);
|
||||
return true;
|
||||
}
|
||||
}
|
51
backend/src/modules/users/entities/user.entity.ts
Normal file
51
backend/src/modules/users/entities/user.entity.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { ObjectType, Field, ID, HideField } from '@nestjs/graphql';
|
||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
|
||||
import { Message } from '../../messages/entities/message.entity';
|
||||
|
||||
@ObjectType()
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@Field(() => ID)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
@Column({ unique: true })
|
||||
username: string;
|
||||
|
||||
@Field()
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@HideField()
|
||||
@Column()
|
||||
password: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true })
|
||||
avatar?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true })
|
||||
bio?: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ default: false })
|
||||
isOnline: boolean;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
@Column({ nullable: true })
|
||||
lastSeen?: Date;
|
||||
|
||||
@Field(() => [Message])
|
||||
@OneToMany(() => Message, message => message.sender)
|
||||
messages: Message[];
|
||||
|
||||
@Field(() => Date)
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
12
backend/src/modules/users/users.module.ts
Normal file
12
backend/src/modules/users/users.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersResolver } from './users.resolver';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
providers: [UsersResolver, UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
39
backend/src/modules/users/users.resolver.ts
Normal file
39
backend/src/modules/users/users.resolver.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Resolver, Query, Mutation, Args, ID } from '@nestjs/graphql';
|
||||
import { UsersService } from './users.service';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlAuthGuard } from '../auth/guards/gql-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
|
||||
@Resolver(() => User)
|
||||
export class UsersResolver {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Query(() => [User], { name: 'users' })
|
||||
@UseGuards(GqlAuthGuard)
|
||||
findAll() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
@Query(() => User, { name: 'user' })
|
||||
@UseGuards(GqlAuthGuard)
|
||||
findOne(@Args('id', { type: () => ID }) id: string) {
|
||||
return this.usersService.findOne(id);
|
||||
}
|
||||
|
||||
@Query(() => User, { name: 'me' })
|
||||
@UseGuards(GqlAuthGuard)
|
||||
getMe(@CurrentUser() user: User) {
|
||||
return this.usersService.findOne(user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => User)
|
||||
@UseGuards(GqlAuthGuard)
|
||||
updateUser(
|
||||
@CurrentUser() user: User,
|
||||
@Args('bio', { nullable: true }) bio?: string,
|
||||
@Args('avatar', { nullable: true }) avatar?: string,
|
||||
) {
|
||||
return this.usersService.update(user.id, { bio, avatar });
|
||||
}
|
||||
}
|
64
backend/src/modules/users/users.service.ts
Normal file
64
backend/src/modules/users/users.service.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async create(username: string, email: string, password: string): Promise<User> {
|
||||
const existingUser = await this.usersRepository.findOne({
|
||||
where: [{ username }, { email }],
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Пользователь с таким username или email уже существует');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const user = this.usersRepository.create({
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
async findAll(): Promise<User[]> {
|
||||
return this.usersRepository.find();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<User> {
|
||||
const user = await this.usersRepository.findOne({ where: { id } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
async update(id: string, updateData: Partial<User>): Promise<User> {
|
||||
await this.usersRepository.update(id, updateData);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async updateOnlineStatus(id: string, isOnline: boolean): Promise<void> {
|
||||
await this.usersRepository.update(id, {
|
||||
isOnline,
|
||||
lastSeen: isOnline ? null : new Date(),
|
||||
});
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user