# Conflicts:
#	package-lock.json
#	package.json
#	src/app.module.ts
#	src/authentication/authentication.controller.ts
#	src/main.ts
#	src/posts/dto/create-post.dto.ts
#	src/posts/entities/post.entity.ts
#	src/posts/posts.controller.ts
#	src/posts/posts.module.ts
#	src/posts/posts.service.ts
#	src/users/user.service.ts
This commit is contained in:
2025-09-26 19:50:43 +07:00
17 changed files with 379 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import {UsersService} from "../users/user.service";
@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(
Strategy,
'jwt-refresh-token'
) {
constructor(
private readonly configService: ConfigService,
private readonly userService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
return request?.cookies?.Refresh;
}]),
secretOrKey: configService.get('JWT_REFRESH_TOKEN_SECRET'),
passReqToCallback: true,
});
}
async validate(request: Request, payload: TokenPayload) {
const refreshToken = request.cookies?.Refresh;
return this.userService.getUserIfRefreshTokenMatches(refreshToken, payload.userId);
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export default class JwtRefreshGuard extends AuthGuard('jwt-refresh-token') {}

44
src/chat/chat.gateway.ts Normal file
View File

@@ -0,0 +1,44 @@
import {
ConnectedSocket,
MessageBody, OnGatewayConnection,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import {Server, Socket} from 'socket.io';
import {ChatService} from "./chat.service";
@WebSocketGateway()
export class ChatGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
constructor(
private readonly chatService: ChatService
) {
}
async handleConnection(socket: Socket) {
await this.chatService.getUserFromSocket(socket);
}
@SubscribeMessage('send_message')
async listenForMessages(@MessageBody() content: string, @ConnectedSocket() socket: Socket,) {
const author = await this.chatService.getUserFromSocket(socket);
const message = await this.chatService.saveMessage(content, author);
this.server.sockets.emit('receive_message', message);
return message;
}
@SubscribeMessage('request_all_messages')
async requestAllMessages(
@ConnectedSocket() socket: Socket,
) {
await this.chatService.getUserFromSocket(socket);
const messages = await this.chatService.getAllMessages();
socket.emit('send_all_messages', messages);
}
}

47
src/chat/chat.service.ts Normal file
View File

@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { AuthenticationService } from '../authentication/authentication.service';
import { Socket } from 'socket.io';
import { parse } from 'cookie';
import { WsException } from '@nestjs/websockets';
import UserEntity from "../users/entities/user.entity";
import {InjectRepository} from "@nestjs/typeorm";
import Message from "./mesage.entity";
import {Repository} from "typeorm";
@Injectable()
export class ChatService {
constructor(
private readonly authenticationService: AuthenticationService,
@InjectRepository(Message)
private messagesRepository: Repository<Message>,
) {
}
async saveMessage(content: string, author: UserEntity) {
const newMessage = this.messagesRepository.create({
content,
author
});
await this.messagesRepository.save(newMessage);
return newMessage;
}
async getAllMessages() {
return this.messagesRepository.find({
relations: {
author: true,
}
});
}
async getUserFromSocket(socket: Socket) {
const cookie = socket.handshake.headers.cookie;
const { Authentication: authenticationToken } = parse(cookie);
const user = await this.authenticationService.getUserFromAuthenticationToken(authenticationToken);
if (!user) {
throw new WsException('Invalid credentials.');
}
return user;
}
}

16
src/chat/mesage.entity.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import User from '../users/entities/user.entity';
@Entity()
class Message {
@PrimaryGeneratedColumn()
public id: number;
@Column()
public content: string;
@ManyToOne(() => User)
public author: User;
}
export default Message;

View File

@@ -0,0 +1,19 @@
import { CACHE_KEY_METADATA, CacheInterceptor, } from '@nestjs/cache-manager';
import {ExecutionContext, Injectable} from "@nestjs/common";
@Injectable()
export class HttpCacheInterceptor extends CacheInterceptor {
trackBy(context: ExecutionContext): string | undefined {
const cacheKey = this.reflector.get(
CACHE_KEY_METADATA,
context.getHandler(),
);
if (cacheKey) {
const request = context.switchToHttp().getRequest();
return `${cacheKey}-${request._parsedUrl.query}`;
}
return super.trackBy(context);
}
}

View File

@@ -0,0 +1 @@
export const GET_POSTS_CACHE_KEY = 'GET_POSTS_CACHE';

View File

@@ -0,0 +1,98 @@
import {Injectable} from '@nestjs/common';
import {ElasticsearchService} from '@nestjs/elasticsearch';
import Post from "./entities/post.entity";
import {PostSearchResult} from "./types/postSearchResult.interface";
import {PostSearchBody} from "./types/postSearchBody.interface";
import PostCountResult from "./types/postCountBody.interface";
@Injectable()
export default class PostsSearchService {
index = 'posts';
constructor(
private readonly elasticsearchService: ElasticsearchService
) {
}
async indexPost(post: Post) {
return this.elasticsearchService.index<PostSearchBody>({
index: this.index,
document: {
id: post.id,
title: post.title,
content: post.content,
authorId: post.author.id
}
});
}
async count(query: string, fields: string[]) {
const result = await this.elasticsearchService.count({
index: this.index,
query: {
multi_match: {
query,
fields,
},
},
});
return result.count;
}
async search(text: string) {
const result = await this.elasticsearchService.search<PostSearchResult>({
index: this.index,
query: {
multi_match: {
query: text,
fields: ['title', 'content'],
},
},
});
const hits = result.hits.hits;
return hits.map((item) => item._source);
}
async remove(postId: number) {
await this.elasticsearchService.deleteByQuery({
index: this.index,
query: {
match: {
id: postId,
}
}
})
}
async update(post: Post) {
const newBody: PostSearchBody = {
id: post.id,
title: post.title,
content: post.content,
authorId: post.author.id
};
const script = Object.entries(newBody).reduce((result, [key, value]) => {
return `${result} ctx._source.${key}='${value}';`;
}, '');
await this.elasticsearchService.updateByQuery({
index: this.index,
query: {
match: {
id: post.id,
}
},
script: {
source: script
}
});
}
}

View File

@@ -0,0 +1,5 @@
interface PostCountResult {
count: number;
}
export default PostCountResult;

View File

@@ -0,0 +1,6 @@
export interface PostSearchBody {
id: number,
title: string,
content: string,
authorId: number
}

View File

@@ -0,0 +1,10 @@
import {PostSearchBody} from "./postSearchBody.interface";
export interface PostSearchResult {
hits: {
total: number;
hits: Array<{
_source: PostSearchBody;
}>;
};
}

View File

@@ -0,0 +1,19 @@
import { Column, Entity, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import Product from '../products/product.entity';
@Entity()
class ProductCategory {
@PrimaryGeneratedColumn()
public id: number;
@Column()
public name: string;
@OneToMany(
() => Product,
(product: Product) => product.category,
)
public products: Product[];
}
export default ProductCategory;

View File

@@ -0,0 +1,23 @@
import { Column, Entity, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import ProductCategory from '../productCategories/productCategory.entity';
import { CarProperties } from './types/carProperties.interface';
import { BookProperties } from './types/bookProperties.interface';
@Entity()
class Product {
@PrimaryGeneratedColumn()
public id: number;
@Column()
public name: string;
@ManyToOne(() => ProductCategory, (category: ProductCategory) => category.products)
public category: ProductCategory;
@Column({
type: 'jsonb'
})
public properties: CarProperties | BookProperties;
}
export default Product;

View File

@@ -0,0 +1,4 @@
export interface BookProperties {
authors: string[];
publicationYear: string;
}

View File

@@ -0,0 +1,7 @@
export interface CarProperties {
brand: string;
engine: {
fuel: string;
numberOfCylinders: number;
}
}

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ElasticsearchModule } from '@nestjs/elasticsearch';
@Module({
imports: [
ConfigModule,
ElasticsearchModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
node: configService.get<string>('ELASTICSEARCH_NODE'),
auth: {
username: configService.get<string>('ELASTICSEARCH_USERNAME'),
password: configService.get<string>('ELASTICSEARCH_PASSWORD'),
}
}),
inject: [ConfigService],
}),
],
exports: [ElasticsearchModule]
})
export class SearchModule {}

View File

@@ -0,0 +1,23 @@
import { IsNumber, Min, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
export class PaginationParams {
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
startId?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
offset?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number;
}