Initial commit: Prism messenger with Expo + NestJS + GraphQL + PostgreSQL

This commit is contained in:
Bivekich
2025-08-06 02:19:37 +03:00
commit 6fb83334d6
56 changed files with 24295 additions and 0 deletions

45
backend/src/app.module.ts Normal file
View 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 {}

View 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
View 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();

View 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 {}

View 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);
}
}

View 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,
};
}
}

View File

@ -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;
},
);

View 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;
}
}

View 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;
}
}

View 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 {}

View 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);
}
}

View 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;
}
}

View File

@ -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;
}

View 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;
}

View 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 {}

View 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');
}
}

View 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;
}
}

View 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;
}

View 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 {}

View 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 });
}
}

View 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(),
});
}
}