diff --git a/migrations/1695130227420-init-schema.ts b/migrations/1695130227420-init-schema.ts new file mode 100644 index 0000000..9bd6341 --- /dev/null +++ b/migrations/1695130227420-init-schema.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitSchema1695130227420 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE users ( + id serial4 NOT NULL, + "name" varchar NOT NULL, + "password" varchar NOT NULL, + email varchar NOT NULL, + CONSTRAINT "users_pk" PRIMARY KEY (id), + CONSTRAINT "users_email_unique" UNIQUE (email) + ); + + CREATE TABLE swipes ( + id serial4 NOT NULL, + liked bool NOT NULL, + "createdAt" timestamp NOT NULL DEFAULT now(), + "sourceUserId" int4 NULL, + "targetUserId" int4 NULL, + CONSTRAINT "PK_bb38af5831e2c084a78e3622ff6" PRIMARY KEY (id) + ); + + ALTER TABLE swipes ADD CONSTRAINT "swipes_target_user_fk" FOREIGN KEY ("targetUserId") REFERENCES users(id); + ALTER TABLE swipes ADD CONSTRAINT "swipes_source_user_fk" FOREIGN KEY ("sourceUserId") REFERENCES users(id); + + CREATE TABLE pairs ( + id serial4 NOT NULL, + CONSTRAINT "pairs_pk" PRIMARY KEY (id) + ); + + CREATE TABLE "pair-members" ( + "pairId" int4 NOT NULL, + "userId" int4 NOT NULL, + CONSTRAINT "pair_members_pk" PRIMARY KEY ("pairId", "userId") + ); + + ALTER TABLE "pair-members" ADD CONSTRAINT "pair_members_user_fk" FOREIGN KEY ("userId") REFERENCES users(id); + ALTER TABLE "pair-members" ADD CONSTRAINT "pair_members_pair_fk" FOREIGN KEY ("pairId") REFERENCES pairs(id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TABLE users; + DROP TABLE swipes; + DROP TABLE pairs; + DROP TABLE "pair-members"; + `); + } +} diff --git a/package.json b/package.json index bbd3728..2854f0b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "ts-node ./node_modules/typeorm/cli", + "migrate:run": "npm run typeorm migration:run -- -d ./typeorm.config.ts" }, "dependencies": { "@nestjs/common": "^9.0.0", @@ -51,6 +53,7 @@ "@types/express": "^4.17.13", "@types/jest": "29.5.1", "@types/lodash": "^4.14.198", + "@types/multer": "^1.4.7", "@types/node": "18.16.12", "@types/passport-jwt": "^3.0.9", "@types/passport-local": "^1.0.35", @@ -87,4 +90,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} +} \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 3364a69..aca3bf7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AuthModule } from './auth/auth.module'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { SwipesModule } from './swipes/swipes.module'; +import { PairsModule } from './pairs/pairs.module'; @Module({ imports: [ @@ -15,8 +17,11 @@ import { TypeOrmModule } from '@nestjs/typeorm'; password: process.env.DB_PASSWORD, database: process.env.DB_NAME, entities: ['dist/**/*.entity.js'], + // synchronize: true, // logging: ['query'] }), + SwipesModule, + PairsModule, ], }) export class AppModule {} diff --git a/src/main.ts b/src/main.ts index 0923d71..423f9c9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { NestFactory } from '@nestjs/core'; -import cookieParser from 'cookie-parser'; +import * as cookieParser from 'cookie-parser'; import helmet from 'helmet'; import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; diff --git a/src/pairs/entities/pair-member.entity.ts b/src/pairs/entities/pair-member.entity.ts new file mode 100644 index 0000000..772c366 --- /dev/null +++ b/src/pairs/entities/pair-member.entity.ts @@ -0,0 +1,14 @@ +import { Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Pair } from './pair.entity'; +import { User } from 'src/users/entities/user.entity'; + +@Entity('pair-members') +export class PairMember { + @PrimaryColumn('int', { name: 'pairId' }) + @ManyToOne(() => Pair, (pair) => pair.pairMembers) + pair: Pair; + + @PrimaryColumn('int', { name: 'userId' }) + @ManyToOne(() => User) + user: User; +} diff --git a/src/pairs/entities/pair.entity.ts b/src/pairs/entities/pair.entity.ts new file mode 100644 index 0000000..17fe199 --- /dev/null +++ b/src/pairs/entities/pair.entity.ts @@ -0,0 +1,11 @@ +import { Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { PairMember } from './pair-member.entity'; + +@Entity('pairs') +export class Pair { + @PrimaryGeneratedColumn() + id!: number; + + @OneToMany(() => PairMember, (pairMember) => pairMember.pair) + pairMembers: PairMember[]; +} diff --git a/src/pairs/pairs.module.ts b/src/pairs/pairs.module.ts new file mode 100644 index 0000000..d0b2ceb --- /dev/null +++ b/src/pairs/pairs.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PairsService } from './pairs.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Pair } from './entities/pair.entity'; +import { PairMember } from './entities/pair-member.entity'; +import { UsersModule } from 'src/users/users.module'; +import { Swipe } from 'src/swipes/entities/swipe.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Pair, PairMember, Swipe]), UsersModule], + providers: [PairsService], + exports: [PairsService], +}) +export class PairsModule {} diff --git a/src/pairs/pairs.service.ts b/src/pairs/pairs.service.ts new file mode 100644 index 0000000..c3902a7 --- /dev/null +++ b/src/pairs/pairs.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { Pair } from './entities/pair.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { PairMember } from './entities/pair-member.entity'; +import { UsersService } from 'src/users/users.service'; +import { + UserIdNotExistsException, + UserNotBelongsToPair, +} from 'src/utils/errors'; +import { Swipe } from 'src/swipes/entities/swipe.entity'; + +@Injectable() +export class PairsService { + constructor( + @InjectRepository(Pair) + private readonly pairsRepository: Repository, + @InjectRepository(PairMember) + private readonly pairMembersRepository: Repository, + private readonly usersService: UsersService, + ) {} + + async createPair(userIds: [number, number]) { + const users = await this.usersService.getByIds(userIds); + if (users.length !== 2) { + throw new UserIdNotExistsException(); + } + + const pair = await this.pairsRepository.manager.transaction( + async (manager) => { + const pair = this.pairsRepository.create(); + await manager.save(pair); + const pairMembers = this.pairMembersRepository.create([ + { + pair, + user: { id: userIds[0] }, + }, + { + pair, + user: { id: userIds[1] }, + }, + ]); + await manager.save(pairMembers); + return pair; + }, + ); + + return pair; + } + + async destroyPair(userId: number, pairId: number) { + const pair = await this.pairsRepository.findOne({ + where: { id: pairId }, + relations: { pairMembers: { user: true } }, + }); + const members = pair.pairMembers; + if (!members.some((member: PairMember) => member.user.id === userId)) { + throw new UserNotBelongsToPair(); + } + const anotherMember = members.find( + (member: PairMember) => member.user.id !== userId, + ); + + await this.pairsRepository.manager.transaction(async (manager) => { + await manager.delete(PairMember, { pair }); + await manager.delete(Pair, { id: pairId }); + await manager.update( + Swipe, + { + sourceUser: { id: userId }, + targetUser: { id: anotherMember.user.id }, + }, + { liked: false }, + ); + }); + } +} diff --git a/src/swipes/entities/swipe.entity.ts b/src/swipes/entities/swipe.entity.ts new file mode 100644 index 0000000..90f1d8d --- /dev/null +++ b/src/swipes/entities/swipe.entity.ts @@ -0,0 +1,26 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from 'src/users/entities/user.entity'; + +@Entity('swipes') +export class Swipe { + @PrimaryGeneratedColumn() + id!: number; + + @ManyToOne(() => User, (user) => user.swipes) + sourceUser: User; + + @ManyToOne(() => User, (user) => user.swiped) + targetUser: User; + + @Column() + liked: boolean; + + @CreateDateColumn({ type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/swipes/swipes.module.ts b/src/swipes/swipes.module.ts new file mode 100644 index 0000000..0f373aa --- /dev/null +++ b/src/swipes/swipes.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Swipe } from './entities/swipe.entity'; +import { SwipesService } from './swipes.service'; +import { UsersModule } from 'src/users/users.module'; +import { PairsModule } from 'src/pairs/pairs.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Swipe]), UsersModule, PairsModule], + providers: [SwipesService], +}) +export class SwipesModule {} diff --git a/src/swipes/swipes.service.ts b/src/swipes/swipes.service.ts new file mode 100644 index 0000000..8f8b85a --- /dev/null +++ b/src/swipes/swipes.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { Swipe } from './entities/swipe.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { UsersService } from 'src/users/users.service'; +import { UserIdNotExistsException } from 'src/utils/errors'; +import { PairsService } from 'src/pairs/pairs.service'; + +@Injectable() +export class SwipesService { + constructor( + @InjectRepository(Swipe) + private readonly swipesRepository: Repository, + private readonly usersService: UsersService, + private readonly pairsService: PairsService, + ) {} + + async createSwipe( + sourceUserId: number, + targetUserId: number, + liked: boolean, + ) { + const users = await this.usersService.getByIds([ + sourceUserId, + targetUserId, + ]); + if (users.length !== 2) { + throw new UserIdNotExistsException(); + } + + const swipe = this.swipesRepository.create({ + sourceUser: { id: sourceUserId }, + targetUser: { id: targetUserId }, + liked, + }); + await this.swipesRepository.save(swipe); + + if (liked) { + const answeringLikeSwipe = await this.swipesRepository.findBy({ + sourceUser: { id: targetUserId }, + targetUser: { id: sourceUserId }, + }); + if (answeringLikeSwipe) { + await this.pairsService.createPair([sourceUserId, targetUserId]); // TODO use transaction? + } + } + + return swipe; + } + + async getSwipesByUser(sourceUserId: number) { + const user = await this.usersService.getById(sourceUserId); + if (!user) { + throw new UserIdNotExistsException(); + } + return this.swipesRepository.findBy({ sourceUser: { id: sourceUserId } }); + } +} diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index 284eed0..ce352a8 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -1,11 +1,7 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { Swipe } from 'src/swipes/entities/swipe.entity'; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; -export enum UserRole { - ADMIN = 'admin', - STANDARD = 'standard', -} - -@Entity('Users') +@Entity('users') export class User { @PrimaryGeneratedColumn() id!: number; @@ -19,10 +15,9 @@ export class User { @Column({ unique: true }) email: string; - @Column({ - type: 'enum', - enum: UserRole, - default: UserRole.STANDARD, - }) - role: UserRole; + @OneToMany(() => Swipe, (swipe) => swipe.sourceUser) + swipes: Swipe[]; + + @OneToMany(() => Swipe, (swipe) => swipe.targetUser) + swiped: Swipe[]; } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 17d12cd..de1d78d 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -2,12 +2,16 @@ import { Injectable } from '@nestjs/common'; import { CreateUserDto } from './dto/create-user.dto'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './entities/user.entity'; -import { Repository } from 'typeorm'; +import { In, Not, Repository } from 'typeorm'; import { UserEmailNotExistsException, UserIdNotExistsException, } from 'src/utils/errors'; +interface GetOptions { + limit: number; +} + @Injectable() export class UsersService { constructor( @@ -31,9 +35,38 @@ export class UsersService { return user; } + async getByIds(ids: number[]) { + return this.usersRepository.findBy({ id: In(ids) }); + } + async create(userData: CreateUserDto) { const user = this.usersRepository.create(userData); await this.usersRepository.save(user); return user; } + + async get(userId: number, options: GetOptions) { + // TODO get user preferences + + const user = await this.usersRepository.findBy({ id: userId }); + if (!user) { + throw new UserIdNotExistsException(); + } + + const users = await this.usersRepository.find({ + where: { + swiped: { + sourceUser: { + id: Not(userId), + }, + }, + }, + relations: { + swiped: true, + }, + take: options.limit || 10, + }); + + return users; + } } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index e177699..4f303a3 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -29,3 +29,9 @@ export class UserIdNotExistsException extends HttpException { super('User with this id does not exist', HttpStatus.NOT_FOUND); } } + +export class UserNotBelongsToPair extends HttpException { + constructor() { + super('User do not belongs to this pair', HttpStatus.BAD_REQUEST); + } +} diff --git a/typeorm.config.ts b/typeorm.config.ts new file mode 100644 index 0000000..943def1 --- /dev/null +++ b/typeorm.config.ts @@ -0,0 +1,18 @@ +import { DataSource } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { config } from 'dotenv'; + +config(); + +const configService = new ConfigService(); + +export default new DataSource({ + type: 'postgres', + host: configService.get('DB_HOST'), + port: +configService.get('DB_PORT'), + username: configService.get('DB_USER'), + password: configService.get('DB_PASSWORD'), + database: configService.get('DB_NAME'), + entities: ['dist/**/*.entity.js'], + migrations: ['migrations/**/*'], +}); diff --git a/yarn.lock b/yarn.lock index ff8d4d0..606c05d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1053,6 +1053,13 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== +"@types/multer@^1.4.7": + version "1.4.7" + resolved "https://registry.yarnpkg.com/@types/multer/-/multer-1.4.7.tgz#89cf03547c28c7bbcc726f029e2a76a7232cc79e" + integrity sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA== + dependencies: + "@types/express" "*" + "@types/node@*": version "20.6.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.0.tgz#9d7daa855d33d4efec8aea88cd66db1c2f0ebe16"