diff --git a/BE/musicspot/src/app.module.ts b/BE/musicspot/src/app.module.ts index 3c1f494..ac5e305 100644 --- a/BE/musicspot/src/app.module.ts +++ b/BE/musicspot/src/app.module.ts @@ -13,7 +13,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import * as dotenv from 'dotenv'; import { User } from './user/entities/user.entity'; import { Journey } from './journey/entities/journey.entity'; -import {Spot} from './spot/entities/spot.entity' +import { Spot } from './spot/entities/spot.entity'; +import { PhotoModule } from './photo/module/photo.module'; +import { Photo } from './photo/entity/photo.entity'; +import { DataSource } from 'typeorm'; +import { SpotV2 } from './spot/entities/spot.v2.entity'; +import { JourneyV2 } from './journey/entities/journey.v2.entity'; dotenv.config(); @Module({ @@ -25,9 +30,7 @@ dotenv.config(); username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, - entities: [ - User, Journey, Spot, - ], + entities: [User, Journey, Spot, Photo, SpotV2, JourneyV2], synchronize: false, legacySpatialSupport: false, }), @@ -38,11 +41,13 @@ dotenv.config(); JourneyModule, UserModule, SpotModule, + PhotoModule, ], controllers: [AppController, ReleaseController], providers: [AppService], }) export class AppModule { + constructor(private dataSource: DataSource) {} configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes('/*'); } diff --git a/BE/musicspot/src/journey/controller/journey.controller.ts b/BE/musicspot/src/journey/controller/journey.controller.ts index fcb88bf..7731ca2 100644 --- a/BE/musicspot/src/journey/controller/journey.controller.ts +++ b/BE/musicspot/src/journey/controller/journey.controller.ts @@ -9,6 +9,8 @@ import { Param, Delete, Version, + UseInterceptors, + UploadedFiles, } from '@nestjs/common'; import { JourneyService } from '../service/journey.service'; import { StartJourneyReqDTO } from '../dto/journeyStart/journeyStart.dto'; @@ -44,12 +46,18 @@ import { EndJourneyReqDTOV2, EndJourneyResDTOV2, } from '../dto/v2/endJourney.v2.dto'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { + RecordSpotReqDTOV2, + RecordSpotResDTOV2, +} from '../../spot/dto/v2/recordSpot.v2.dto'; +import { JourneyV2DTO } from '../dto/v2/jounrey.dto'; @Controller('journey') -@ApiTags('journey 관련 API') export class JourneyController { constructor(private journeyService: JourneyService) {} + @ApiTags('Journey V1') @ApiOperation({ summary: '여정 시작 API', description: '여정 기록을 시작합니다.', @@ -63,20 +71,25 @@ export class JourneyController { return await this.journeyService.insertJourneyData(startJourneyDTO); } - @Version('2') - @ApiOperation({ - summary: '여정 시작 API(V2)', - description: '여정 기록을 시작합니다.', - }) - @ApiCreatedResponse({ - description: '생성된 여정 데이터를 반환', - type: StartJourneyResDTOV2, - }) - @Post('start') - async createV2(@Body() startJourneyDTO: StartJourneyReqDTOV2) { - return await this.journeyService.insertJourneyDataV2(startJourneyDTO); - } + // @Version('2') + // @ApiOperation({ + // summary: '여정 시작 API(V2)', + // description: '여정 기록을 시작합니다.', + // }) + // @ApiCreatedResponse({ + // description: '생성된 여정 데이터를 반환', + // type: StartJourneyResDTOV2, + // }) + // @Post('start') + // async createV2(@Body() startJourneyDTO: StartJourneyReqDTOV2) { + // try { + // return await this.journeyService.insertJourneyDataV2(startJourneyDTO); + // } catch (err) { + // console.log(err); + // } + // } + @ApiTags('Journey V1') @ApiOperation({ summary: '여정 종료 API', description: '여정을 종료합니다.', @@ -90,6 +103,7 @@ export class JourneyController { return await this.journeyService.end(endJourneyReqDTO); } + @ApiTags('Journey V2') @Version('2') @ApiOperation({ summary: '여정 종료 API(V2)', @@ -99,11 +113,19 @@ export class JourneyController { description: '여정 종료 정보 반환', type: EndJourneyResDTOV2, }) - @Post('end') - async endV2(@Body() endJourneyReqDTO: EndJourneyReqDTOV2) { - return await this.journeyService.endV2(endJourneyReqDTO); + @Post(':journeyId/end') + async endV2( + @Param('journeyId') journeyId: number, + @Body() endJourneyReqDTO: EndJourneyReqDTOV2, + ) { + try { + return await this.journeyService.endV2(journeyId, endJourneyReqDTO); + } catch (err) { + console.log(err); + } } + @ApiTags('Journey V1') @ApiOperation({ summary: '여정 좌표 기록API', description: '여정의 좌표를 기록합니다.', @@ -119,52 +141,54 @@ export class JourneyController { return returnData; } - @Version('2') - @ApiOperation({ - summary: '여정 조회 API', - description: '해당 범위 내의 여정들을 반환합니다.', - }) - @ApiQuery({ - name: 'userId', - description: '유저 ID', - required: true, - example: 'yourUserId', - }) - @ApiQuery({ - name: 'minCoordinate', - description: '최소 좌표', - required: true, - example: '37.5 127.0', - }) - @ApiQuery({ - name: 'maxCoordinate', - description: '최대 좌표', - required: true, - example: '38.0 128.0', - }) - @ApiCreatedResponse({ - description: '범위에 있는 여정의 기록들을 반환', - type: CheckJourneyResDTO, - }) - @Get() - @UsePipes(ValidationPipe) - async getJourneyByCoordinate( - @Query('userId') userId: UUID, - @Query('minCoordinate') minCoordinate: string, - @Query('maxCoordinate') maxCoordinate: string, - ) { - console.log('min:', minCoordinate, 'max:', maxCoordinate); - const checkJourneyDTO = { - userId, - minCoordinate, - maxCoordinate, - }; - return await this.journeyService.getJourneyByCoordinationRangeV2( - checkJourneyDTO, - ); - } + // @Version('2') + // @ApiOperation({ + // summary: '여정 조회 API', + // description: '해당 범위 내의 여정들을 반환합니다.', + // }) + // @ApiQuery({ + // name: 'userId', + // description: '유저 ID', + // required: true, + // example: 'yourUserId', + // }) + // @ApiQuery({ + // name: 'minCoordinate', + // description: '최소 좌표', + // required: true, + // example: '37.5 127.0', + // }) + // @ApiQuery({ + // name: 'maxCoordinate', + // description: '최대 좌표', + // required: true, + // example: '38.0 128.0', + // }) + // @ApiCreatedResponse({ + // description: '범위에 있는 여정의 기록들을 반환', + // type: CheckJourneyResDTO, + // }) + // @Get() + // @UsePipes(ValidationPipe) + // async getJourneyByCoordinate( + // @Query('userId') userId: UUID, + // @Query('minCoordinate') minCoordinate: string, + // @Query('maxCoordinate') maxCoordinate: string, + // ) { + // console.log('min:', minCoordinate, 'max:', maxCoordinate); + // const checkJourneyDTO = { + // userId, + // minCoordinate, + // maxCoordinate, + // }; + // return await this.journeyService.getJourneyByCoordinationRangeV2( + // checkJourneyDTO, + // ); + // } + + @ApiTags('Journey V1') @ApiOperation({ - summary: '여정 조회 API', + summary: '여정 조회 API(Coordinate 범위)', description: '해당 범위 내의 여정들을 반환합니다.', }) @ApiQuery({ @@ -211,21 +235,27 @@ export class JourneyController { ); } - @Version('2') - @ApiOperation({ - summary: '최근 여정 조회 API', - description: '진행 중인 여정이 있었는 지 확인', - }) - @ApiCreatedResponse({ - description: '사용자가 진행중이었던 여정 정보', - type: LastJourneyResDTO, - }) - @Get('last') - async loadLastDataV2(@Body('userId') userId) { - return await this.journeyService.getLastJourneyByUserIdV2(userId); - } + // @Version('2') + // @ApiOperation({ + // summary: '최근 여정 조회 API', + // description: '진행 중인 여정이 있었는 지 확인', + // }) + // @ApiCreatedResponse({ + // description: '사용자가 진행중이었던 여정 정보', + // type: LastJourneyResDTO, + // }) + // @Get('last') + // async loadLastDataV2(@Body('userId') userId) { + // try { + // return await this.journeyService.getLastJourneyByUserIdV2(userId); + // } catch (err) { + // console.log(err); + // } + // } + + @ApiTags('Journey V1') @ApiOperation({ - summary: '최근 여정 조회 API', + summary: '마지막 여정 진행 중 여부 확인 API', description: '진행 중인 여정이 있었는 지 확인', }) @ApiCreatedResponse({ @@ -236,23 +266,29 @@ export class JourneyController { async loadLastData(@Body('userId') userId) { return await this.journeyService.getLastJourneyByUserId(userId); } - + @ApiTags('Journey V2') @Version('2') @ApiOperation({ - summary: '여정 조회 API', + summary: '여정 조회 API(journeyId)', description: 'journey id를 통해 여정을 조회', }) @ApiCreatedResponse({ description: 'journey id에 해당하는 여정을 반환', - type: [Journey], + type: JourneyV2DTO, + isArray: true, }) @Get(':journeyId') - async getJourneyByIdV2(@Param('journeyId') journeyId: string) { - return await this.journeyService.getJourneyByIdV2(journeyId); + async getJourneyByIdV2(@Param('journeyId') journeyId: number) { + try { + return await this.journeyService.getJourneyByIdV2(journeyId); + } catch (err) { + console.log(err); + } } + @ApiTags('Journey V1') @ApiOperation({ - summary: '여정 조회 API', + summary: '여정 조회 API(journeyId)', description: 'journey id를 통해 여정을 조회', }) @ApiCreatedResponse({ @@ -264,6 +300,7 @@ export class JourneyController { return await this.journeyService.getJourneyById(journeyId); } + @ApiTags('Journey V1') @ApiOperation({ summary: '여정 삭제 api', description: 'journey id에 따른 여정 삭제', @@ -276,4 +313,51 @@ export class JourneyController { async deleteJourneyById(@Body() deleteJourneyDto: DeleteJourneyReqDTO) { return await this.journeyService.deleteJourneyById(deleteJourneyDto); } + + @ApiTags('Spot V2') + @Version('2') + @ApiOperation({ + summary: 'spot 저장 api(V2)', + description: '복수개의 사진을 가지는 spot을 저장', + }) + @ApiCreatedResponse({ + description: '저장된 spot을 반환(presigned url)', + type: RecordSpotResDTOV2, + }) + @UseInterceptors(FilesInterceptor('images')) + @Post(':journeyId/spot') + async saveSpotToJourney( + @UploadedFiles() images: Array, + @Param('journeyId') journeyId: string, + @Body() recordSpotDto: RecordSpotReqDTOV2, + ) { + try { + return await this.journeyService.saveSpot( + images, + journeyId, + recordSpotDto, + ); + } catch (err) { + console.log(err); + } + } + + @ApiTags('Journey V2') + @Version('2') + @ApiOperation({ + summary: 'journey 삭제 API', + description: 'journey id를 통해 journey 데이터 삭제' + }) + @ApiCreatedResponse({ + description: '여정 삭제' + }) + @Delete(':journeyId') + async deleteJourney(@Param('journeyId') journeyId:number){ + try{ + return this.journeyService.deleteJourney(journeyId); + } catch (err){ + console.log(err) + } + } + } diff --git a/BE/musicspot/src/journey/dto/journeyCheck/journeyCheck.dto.ts b/BE/musicspot/src/journey/dto/journeyCheck/journeyCheck.dto.ts index d34cd9e..2dfd170 100644 --- a/BE/musicspot/src/journey/dto/journeyCheck/journeyCheck.dto.ts +++ b/BE/musicspot/src/journey/dto/journeyCheck/journeyCheck.dto.ts @@ -56,7 +56,7 @@ export class SpotDTO { readonly photoUrl: string; } -class journeyMetadataDto { +export class journeyMetadataDto { @ApiProperty({ description: '여정 시작 시간', example: '2023-11-22T15:30:00.000+09:00', diff --git a/BE/musicspot/src/journey/dto/spot/recordSpot.dto.ts b/BE/musicspot/src/journey/dto/spot/recordSpot.dto.ts new file mode 100644 index 0000000..c39d7a0 --- /dev/null +++ b/BE/musicspot/src/journey/dto/spot/recordSpot.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsDateString, IsNumber, IsObject } from 'class-validator'; +import { UUID } from 'crypto'; +import { IsCoordinateV2 } from '../../../common/decorator/coordinate.v2.decorator'; +export class RecordSpotReqDTO { + @ApiProperty({ + example: '37.555941 126.972381', + description: 'spot 위치', + required: true, + }) + @IsCoordinateV2() + readonly coordinate: string; + + @ApiProperty({ + example: '2023-11-22T12:00:56Z', + description: 'spot 기록 시간', + required: true, + }) + @IsString() + readonly timestamp: string; + + + @ApiProperty({ + example: 'song object', + description: 'spot 기록 시간', + }) + @IsObject() + readonly spotSong: object; +} + +export class RecordSpotResDTO { + @ApiProperty({ + example: 20, + description: '여정 id', + required: true, + }) + readonly journeyId: number; + + @ApiProperty({ + example: '37.555946 126.972384', + description: '위치 좌표', + required: true, + }) + readonly coordinate: string; + + @ApiProperty({ + example: '2023-11-22T12:00:00Z', + description: 'timestamp', + required: true, + }) + readonly timestamp: string; + + @ApiProperty({ + example: + 'https://music-spot-storage.kr.object.ncloudstorage.com/path/name?AWSAccessKeyId=key&Expires=1701850233&Signature=signature', + description: 'presigned url', + required: true, + }) + readonly photoUrls: string[]; +} diff --git a/BE/musicspot/src/journey/dto/v2/endJourney.v2.dto.ts b/BE/musicspot/src/journey/dto/v2/endJourney.v2.dto.ts index 7d42f02..2c4c544 100644 --- a/BE/musicspot/src/journey/dto/v2/endJourney.v2.dto.ts +++ b/BE/musicspot/src/journey/dto/v2/endJourney.v2.dto.ts @@ -1,20 +1,16 @@ import { ApiProperty } from '@nestjs/swagger'; -import { - IsString, - IsDateString, - IsNumber, - IsObject, -} from 'class-validator'; +import { IsString, IsDateString, IsNumber, IsObject } from 'class-validator'; import { IsCoordinatesV2 } from '../../../common/decorator/coordinate.v2.decorator'; +import { SongDTO } from '../song/song.dto'; export class EndJourneyReqDTOV2 { - @ApiProperty({ - example: 20, - description: '여정 id', - required: true, - }) - @IsNumber() - readonly journeyId: number; + // @ApiProperty({ + // example: 20, + // description: '여정 id', + // required: true, + // }) + // @IsNumber() + // readonly journeyId: number; @ApiProperty({ example: '37.555946 126.972384,37.555946 126.972384', @@ -47,6 +43,7 @@ export class EndJourneyReqDTOV2 { @ApiProperty({ description: '노래 정보', required: true, + type: SongDTO, }) readonly song: object; } @@ -83,6 +80,7 @@ export class EndJourneyResDTOV2 { @ApiProperty({ description: '노래 정보', required: true, + type: SongDTO, }) readonly song: object; } diff --git a/BE/musicspot/src/journey/dto/v2/jounrey.dto.ts b/BE/musicspot/src/journey/dto/v2/jounrey.dto.ts new file mode 100644 index 0000000..a2cc810 --- /dev/null +++ b/BE/musicspot/src/journey/dto/v2/jounrey.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { journeyMetadataDto, SpotDTO } from '../journeyCheck/journeyCheck.dto'; +import { SpotV2DTO } from '../../../spot/dto/v2/spot.dto'; +import { SongDTO } from '../song/song.dto'; + +export class JourneyV2DTO { + @ApiProperty({ description: '여정 ID', example: '65649c91380cafcab8869ed2' }) + readonly journeyId: string; + + @ApiProperty({ description: '여정 제목', example: '여정 제목' }) + readonly title: string; + + @ApiProperty({ + example: '37.555946 126.972384,37.555946 126.972384', + description: '위치 좌표', + required: true, + }) + readonly coordinates: string; + + @ApiProperty({ description: '여정 메타데이터', type: journeyMetadataDto }) + readonly journeyMetadata: journeyMetadataDto; + + @ApiProperty({ type: SpotV2DTO, description: 'spot 배열', isArray: true }) + readonly spots: SpotV2DTO[]; + + @ApiProperty({ type: SongDTO, description: '여정 대표 음악' }) + readonly song: SongDTO; +} + +// diff --git a/BE/musicspot/src/journey/dto/v2/lastJourney.dto.v2.ts b/BE/musicspot/src/journey/dto/v2/lastJourney.dto.v2.ts new file mode 100644 index 0000000..cc7876e --- /dev/null +++ b/BE/musicspot/src/journey/dto/v2/lastJourney.dto.v2.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { JourneyV2DTO } from './jounrey.dto'; + +export class LastJourneyResV2DTO { + @ApiProperty({ description: '여정 ID', type: JourneyV2DTO }) + readonly jounrey: JourneyV2DTO; + + @ApiProperty({ description: '여정 마무리 여부' }) + readonly isRecording: boolean; +} diff --git a/BE/musicspot/src/journey/entities/journey.entity.ts b/BE/musicspot/src/journey/entities/journey.entity.ts index cdf3987..692adeb 100644 --- a/BE/musicspot/src/journey/entities/journey.entity.ts +++ b/BE/musicspot/src/journey/entities/journey.entity.ts @@ -1,30 +1,36 @@ -import { UUID } from "crypto"; -import { Spot } from "../../spot/entities/spot.entity"; -import {Entity, Column, PrimaryGeneratedColumn, OneToMany, JoinColumn} from "typeorm"; +import { UUID } from 'crypto'; +import { Spot } from '../../spot/entities/spot.entity'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + OneToMany, + JoinColumn, +} from 'typeorm'; @Entity() -export class Journey{ - @PrimaryGeneratedColumn() - journeyId: number; +export class Journey { + @PrimaryGeneratedColumn() + journeyId: number; - @Column({length : 36}) - userId : UUID; + @Column({ length: 36 }) + userId: UUID; - @Column({length : 30}) - title : string; + @Column({ length: 30 }) + title: string; - @Column() - startTimestamp : string; + @Column() + startTimestamp: string; - @Column() - endTimestamp : string; + @Column() + endTimestamp: string; - @Column() - song : string; - - @Column("geometry") - coordinates:string; + @Column() + song: string; - // @OneToMany(()=> Spot, spot => spot.journeyId) - @OneToMany(()=> Spot, (spot)=>spot.journey) - spots: Spot[]; -} \ No newline at end of file + @Column('geometry') + coordinates: string; + + // @OneToMany(()=> Spot, spot => spot.journeyId) + @OneToMany(() => Spot, (spot) => spot.journey) + spots: Spot[]; +} diff --git a/BE/musicspot/src/journey/entities/journey.v2.entity.ts b/BE/musicspot/src/journey/entities/journey.v2.entity.ts new file mode 100644 index 0000000..3ece5b1 --- /dev/null +++ b/BE/musicspot/src/journey/entities/journey.v2.entity.ts @@ -0,0 +1,36 @@ +import { UUID } from 'crypto'; +import { SpotV2 } from '../../spot/entities/spot.v2.entity'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + OneToMany, + JoinColumn, +} from 'typeorm'; +@Entity({ name: 'journey'}) +export class JourneyV2 { + @PrimaryGeneratedColumn() + journeyId: number; + + @Column({ length: 36 }) + userId: UUID; + + @Column({ length: 30 }) + title: string; + + @Column() + startTimestamp: string; + + @Column() + endTimestamp: string; + + @Column() + song: string; + + @Column('geometry') + coordinates: string; + + // @OneToMany(()=> Spot, spot => spot.journeyId) + @OneToMany(() => SpotV2, (spot) => spot.journey) + spots: SpotV2[]; +} diff --git a/BE/musicspot/src/journey/module/journey.module.ts b/BE/musicspot/src/journey/module/journey.module.ts index 8030d43..97ef97d 100644 --- a/BE/musicspot/src/journey/module/journey.module.ts +++ b/BE/musicspot/src/journey/module/journey.module.ts @@ -12,12 +12,26 @@ import { UserRepository } from 'src/user/repository/user.repository'; import { TypeOrmExModule } from 'src/dynamic.module'; import { SpotRepository } from 'src/spot/repository/spot.repository'; import { Spot } from '../../spot/entities/spot.entity'; +import { PhotoRepository } from '../../photo/photo.repository'; +import { Photo } from '../../photo/entity/photo.entity'; +import { Journey } from '../entities/journey.entity'; +import { User } from '../../user/entities/user.entity'; +import { JourneyV2 } from '../entities/journey.v2.entity'; +import { SpotV2 } from '../../spot/entities/spot.v2.entity'; @Module({ imports: [ - TypeOrmExModule.forFeature([JourneyRepository, UserRepository, SpotRepository, Spot]), + // TypeOrmExModule.forFeature([ + // JourneyRepository, + // UserRepository, + // SpotRepository, + // Spot, + // Photo, + // PhotoRepository, + // ]), // MongooseModule.forFeature([{ name: Journey.name, schema: JourneySchema }]), // MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), + TypeOrmModule.forFeature([Journey, User, Spot, Photo, JourneyV2, SpotV2]), UserModule, ], controllers: [JourneyController], diff --git a/BE/musicspot/src/journey/repository/journey.repository.ts b/BE/musicspot/src/journey/repository/journey.repository.ts index 1116bac..85fdfbf 100644 --- a/BE/musicspot/src/journey/repository/journey.repository.ts +++ b/BE/musicspot/src/journey/repository/journey.repository.ts @@ -1,6 +1,6 @@ -import { CustomRepository } from "src/common/decorator/customRepository.decorator"; -import { Repository } from "typeorm"; -import { Journey } from "../entities/journey.entity"; +import { CustomRepository } from '../../common/decorator/customRepository.decorator'; +import { Repository } from 'typeorm'; +import { Journey } from '../entities/journey.entity'; @CustomRepository(Journey) -export class JourneyRepository extends Repository{}; \ No newline at end of file +export class JourneyRepository extends Repository {} diff --git a/BE/musicspot/src/journey/service/journey.repository.spec.ts b/BE/musicspot/src/journey/service/journey.repository.spec.ts new file mode 100644 index 0000000..f83a425 --- /dev/null +++ b/BE/musicspot/src/journey/service/journey.repository.spec.ts @@ -0,0 +1,100 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JourneyService } from './journey.service'; +import { UserService } from '../../user/serivce/user.service'; +import { JourneyRepository } from '../repository/journey.repository'; +import { UserRepository } from '../../user/repository/user.repository'; +import { SpotRepository } from '../../spot/repository/spot.repository'; +import { PhotoRepository } from '../../photo/photo.repository'; +import { getRepositoryToken, TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '../../user/entities/user.entity'; +import { Journey } from '../entities/journey.entity'; +import { Spot } from '../../spot/entities/spot.v2.entity'; +import { Photo } from '../../photo/entity/photo.entity'; +import { Repository } from 'typeorm'; +import * as dotenv from 'dotenv'; +import { RecordSpotReqDTO } from '../dto/spot/recordSpot.dto'; +import { parseCoordinateFromDtoToGeoV2 } from '../../common/util/coordinate.v2.util'; + +dotenv.config(); +let journeyRepository; +let spotRepository; +let photoRepository; +beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [], + imports: [ + TypeOrmModule.forRoot({ + type: 'mysql', + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: [User, Journey, Spot, Photo], + synchronize: false, + legacySpatialSupport: false, + }), + TypeOrmModule.forFeature([Journey, Spot, Photo]), + ], + }).compile(); + + journeyRepository = module.get(getRepositoryToken(Journey)); + spotRepository = module.get(getRepositoryToken(Spot)); + photoRepository = module.get(getRepositoryToken(Photo)); +}); + +describe('db 테스트', () => { + it('save test', async () => { + const journeyData = { + title: 'test', + }; + + console.log(await journeyRepository.save(journeyData)); + }); + + it('bulk insert 테스트', async () => { + const spotId = 20; + const keys = ['20/315131', '20/313651365']; + + console.log( + await photoRepository.save( + keys.map((key) => { + return { + spotId, + photoKey: key, + }; + }), + ), + ); + }); + + it('spot save 테스트', async () => { + const song = { + id: '655efda2fdc81cae36d20650', + name: 'super shy', + artistName: 'newjeans', + artwork: { + width: 3000, + height: 3000, + url: 'https://is3-ssl.mzstatic.com/image/thumb/Music125/v4/0b/b2/52/0bb2524d-ecfc-1bae-9c1e-218c978d7072/Honeymoon_3K.jpg/{w}x{h}bb.jpg', + bgColor: '3000', + }, + }; + const journeyId = 20; + const recordSpotDto: RecordSpotReqDTO = { + coordinate: '37.555946 126.972384', + timestamp: '2023-11-22T12:12:11Z', + spotSong: song, + }; + + const data = { + ...recordSpotDto, + journeyId, + spotSong: JSON.stringify(recordSpotDto.spotSong), + coordinate: parseCoordinateFromDtoToGeoV2(recordSpotDto.coordinate), + // photoKey: 'asd', + }; + + console.log(await spotRepository.save(data)); + }); +}); diff --git a/BE/musicspot/src/journey/service/journey.service.spec.ts b/BE/musicspot/src/journey/service/journey.service.spec.ts index 43f23da..6a675d6 100644 --- a/BE/musicspot/src/journey/service/journey.service.spec.ts +++ b/BE/musicspot/src/journey/service/journey.service.spec.ts @@ -1,162 +1,142 @@ import { Test, TestingModule } from '@nestjs/testing'; import { JourneyService } from './journey.service'; -import { User } from '../../user/schema/user.schema'; -import { Journey } from '../schema/journey.schema'; -import { getModelToken } from '@nestjs/mongoose'; -import { StartJourneyDTO } from '../dto/journeyStart/journeyStartReq.dto'; import { UserService } from '../../user/serivce/user.service'; -import { EndJourneyDTO } from '../dto/journeyEnd/journeyEndReq.dto'; -import { UserNotFoundException } from '../../filters/user.exception'; +import * as fs from 'fs'; +import { JourneyRepository } from '../repository/journey.repository'; +import { UserRepository } from '../../user/repository/user.repository'; +import { SpotRepository } from '../../spot/repository/spot.repository'; +import { RecordSpotReqDTO, RecordSpotResDTO } from '../dto/spot/recordSpot.dto'; +import { makePresignedUrl } from '../../common/s3/objectStorage'; +import { PhotoRepository } from '../../photo/photo.repository'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Photo } from '../../photo/entity/photo.entity'; +import { Journey } from '../entities/journey.entity'; +import { User } from '../../user/entities/user.entity'; +import { Spot } from '../../spot/entities/spot.v2.entity'; let service: JourneyService; -let userModel; -let journeyModel; -beforeAll(async () => { - const MockUserModel = { - findOneAndUpdate: jest.fn(), - }; - const MockJourneyModel = { - findOneAndUpdate: jest.fn(), - save: jest.fn(), - lean: jest.fn(), +jest.mock('aws-sdk', () => { + return { + S3: jest.fn(() => ({ + putObject: jest.fn(() => ({ + promise: jest.fn().mockResolvedValue('fake'), + })), + getSignedUrl: jest.fn().mockResolvedValue('presigned url'), + })), }; +}); +const mockPhotoRepository = { + save: jest.fn(), +}; +const mockSpotRepository = { + save: jest.fn(), +}; +beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ JourneyService, UserService, + // JourneyRepository, + // UserRepository, + // SpotRepository, + { + provide: getRepositoryToken(Photo), + useValue: mockPhotoRepository, + }, { - provide: getModelToken(Journey.name), - useValue: MockJourneyModel, + provide: getRepositoryToken(Journey), + useValue: mockPhotoRepository, }, { - provide: getModelToken(User.name), - useValue: MockUserModel, + provide: getRepositoryToken(User), + useValue: mockPhotoRepository, }, + { + provide: getRepositoryToken(Spot), + useValue: mockSpotRepository, + }, + JourneyRepository, ], }).compile(); - service = module.get(JourneyService); - userModel = module.get(getModelToken(User.name)); - journeyModel = module.get(getModelToken(Journey.name)); }); -describe('여정 시작 관련 service 테스트', () => { - it('insertJourneyData 성공 테스트', async () => { - const data: StartJourneyDTO = { - coordinate: [37.555946, 126.972384], - startTime: '2023-11-22T12:00:00Z', - userId: 'ab4068ef-95ed-40c3-be6d-3db35df866b9', +describe('test', () => { + it('spot 저장 테스트', async () => { + // given + const song = { + id: '655efda2fdc81cae36d20650', + name: 'super shy', + artistName: 'newjeans', + artwork: { + width: 3000, + height: 3000, + url: 'https://is3-ssl.mzstatic.com/image/thumb/Music125/v4/0b/b2/52/0bb2524d-ecfc-1bae-9c1e-218c978d7072/Honeymoon_3K.jpg/{w}x{h}bb.jpg', + bgColor: '3000', + }, }; - - journeyModel.save.mockResolvedValue({ - title: '', - spots: [], - coordinates: [[37.555946, 126.972384]], - startTime: '2023-11-22T12:00:00Z', - endTime: '', - _id: '65673e666b2fb1462684b2c7', - __v: 0, + const journeyId = 20; + const recordSpotDto: RecordSpotReqDTO = { + coordinate: '37.555946 126.972384', + timestamp: '2023-11-22T12:12:11Z', + spotSong: song, + }; + const files = [ + { + buffer: fs.readFileSync(`${__dirname}/test-image/test.png`), + }, + { buffer: fs.readFileSync(`${__dirname}/test-image/test1.png`) }, + ]; + // when + // 1. 이미지 s3 업로드 (key 반환) + // savePhotoToS3(files, journeyId) + const keys = await service.savePhotoToS3(files, journeyId); + console.log(keys); + expect(keys.length).toEqual(2); + // const reuturnedData: RecordSpotResDTO = { + // journeyId: journeyId, + // ...recordSpotDto, + // photoUrls: keys.map((key) => makePresignedUrl(key)), + // }; + // // 2. spot 저장(저장된 spot 반환) + // // saveSpot(keys, dto) + mockSpotRepository.save.mockResolvedValue({ + spotId: 2, }); + const result = await service.saveSpotDtoToSpot( + keys, + journeyId, + recordSpotDto, + ); + console.log(result); + const { spotId } = result; + expect(spotId).toEqual(2); - try { - const returnData = await service.insertJourneyData(data); - const { title, spots, coordinates, startTime, endTime } = returnData; - expect(title).toEqual(''); - expect(spots).toEqual([]); - expect(coordinates[0]).toEqual(data.coordinate); - expect(startTime).toEqual(data.startTime); - expect(endTime).toEqual(''); - } catch (err) { - console.log(err); - } - }); + mockPhotoRepository.save.mockResolvedValue([ + { spotId, photoKey: keys[0] }, + { spotId, photoKey: keys[1] }, + ]); - it('pushJourneyIdToUser 실패 테스트', async () => { - userModel.findOneAndUpdate.mockReturnValue({ - lean: jest.fn().mockResolvedValue(false), - }); - try { - service.pushJourneyIdToUser( - '655efda2fdc81cae36d20650', - 'ab4068ef-95ed-40c3-be6d-3db35df866b9', - ); - expect(1).toEqual(1); - } catch (err) { - expect(err).toThrow(UserNotFoundException); - } - }); -}); + const result2 = await service.savePhotoKeysToPhoto(result.spotId, keys); -describe('여정 마무리 관련 service 테스트', () => { - it('end 성공 테스트', async () => { - journeyModel.findOneAndUpdate.mockReturnValue({ - lean: jest.fn().mockResolvedValue({ - _id: '655efda2fdc81cae36d20650', - title: 'title', - spots: [], - coordinates: [[37.555946, 126.972384]], - startTime: '2023-11-22T12:00:00Z', - endTime: '2023-11-22T12:30:00Z', - __v: 0, - }), - }); - const endData: EndJourneyDTO = { - journeyId: '655efda2fdc81cae36d20650', - coordinate: [37.555946, 126.972384], - endTime: '2023-11-22T12:30:00Z', - title: 'title', - }; + console.log(result2); + expect(result2.length).toEqual(2); - const returnData = await service.end(endData); - const { title, endTime } = returnData; - expect(title).toEqual(endData.title); - expect(endTime).toEqual(endData.endTime); + // then }); -}); -// describe.skip('여정 기록 관련 service 테스트', () => { -// it('pushCoordianteToJourney 성공 테스트', async () => { -// const data: RecordJourneyDTO = { -// journeyId: '655f8bd2ceab803bb2d566bc', -// coordinate: [37.555947, 126.972385], -// }; -// journeyModel.findOneAndUpdate.mockReturnValue({ -// lean: jest.fn().mockResolvedValue({ -// _id: '65673e666b2fb1462684b2c7', -// title: '', -// spots: [], -// coordinates: [[37.555947, 126.972385]], -// timestamp: '2023-11-22T12:00:00Z', -// __v: 0, -// }), -// }); -// try { -// const returnData = await service.pushCoordianteToJourney(data); -// const { coordinates } = returnData; -// const lastCoorinate = coordinates[coordinates.length - 1]; -// expect(lastCoorinate).toEqual(data.coordinate); -// } catch (err) { -// expect(err.status).toEqual(404); -// expect(err.message).toEqual(JourneyExceptionMessageEnum.JourneyNotFound); -// } -// }); + it('photo 저장 테스트', async () => { + const spotId = 20; + const keys = ['20/315131', '20/313651365']; + mockPhotoRepository.save.mockResolvedValue([ + { spotId: spotId, photoKey: keys[0], photoId: 0 }, + { spotId: spotId, photoKey: keys[1], photoId: 1 }, + ]); -// it('pushCoordianteToJourney 실패 테스트(journeyId 없는 경우)', async () => { -// journeyModel.findOneAndUpdate.mockReturnValue({ -// lean: jest.fn().mockResolvedValue(null), -// }); -// const data: RecordJourneyDTO = { -// journeyId: '655f8bd2ceab803bb2d566bc', -// coordinate: [37.555947, 126.972385], -// }; -// try { -// const returnData = await service.pushCoordianteToJourney(data); -// expect(returnData).toBeDefined(); -// } catch (err) { -// expect(err.status).toEqual(404); -// expect(err.message).toEqual(JourneyExceptionMessageEnum.JourneyNotFound); -// } -// }); -// }); + const result = await service.savePhotoKeysToPhoto(spotId, keys); + + expect(result.length).toEqual(2); + }); +}); diff --git a/BE/musicspot/src/journey/service/journey.service.ts b/BE/musicspot/src/journey/service/journey.service.ts index bc71e10..8fb55b2 100644 --- a/BE/musicspot/src/journey/service/journey.service.ts +++ b/BE/musicspot/src/journey/service/journey.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { JourneyRepository } from '../repository/journey.repository'; import { StartJourneyReqDTO, StartJourneyResDTO, @@ -20,13 +19,15 @@ import { is1DArray, parseCoordinateFromGeoToDto, parseCoordinatesFromGeoToDto, -} from 'src/common/util/coordinate.util'; +} from '../../common/util/coordinate.util'; import { DeleteJourneyReqDTO } from '../dto/journeyDelete.dto'; -import { UserRepository } from 'src/user/repository/user.repository'; import { Journey } from '../entities/journey.entity'; -import { makePresignedUrl } from 'src/common/s3/objectStorage'; -import { parse } from 'path'; +import { + bucketName, + makePresignedUrl, + S3, +} from '../../common/s3/objectStorage'; import { StartJourneyReqDTOV2, StartJourneyResDTOV2, @@ -34,16 +35,29 @@ import { import { EndJourneyReqDTOV2 } from '../dto/v2/endJourney.v2.dto'; import { isPointString, + parseCoordinateFromDtoToGeoV2, parseCoordinateFromGeoToDtoV2, parseCoordinatesFromGeoToDtoV2, } from '../../common/util/coordinate.v2.util'; import { UserNotFoundException } from '../../filters/user.exception'; +import { Photo } from '../../photo/entity/photo.entity'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Spot } from '../../spot/entities/spot.entity'; +import { User } from '../../user/entities/user.entity'; +import { SpotV2 } from '../../spot/entities/spot.v2.entity'; +import { JourneyV2 } from '../entities/journey.v2.entity'; @Injectable() export class JourneyService { constructor( - private journeyRepository: JourneyRepository, - private userRepository: UserRepository, + @InjectRepository(Journey) private journeyRepository: Repository, + @InjectRepository(Spot) private spotRepository: Repository, + @InjectRepository(User) private userRepository: Repository, + @InjectRepository(Photo) private photoRepository: Repository, + @InjectRepository(SpotV2) private spotRepositoryV2: Repository, + @InjectRepository(JourneyV2) + private journeyRepositoryV2: Repository, ) {} async insertJourneyData(startJourneyDTO: StartJourneyReqDTO) { @@ -69,7 +83,7 @@ export class JourneyService { } async insertJourneyDataV2(startJourneyDTO: StartJourneyReqDTOV2) { - const returnedData = await this.journeyRepository.save(startJourneyDTO); + const returnedData = await this.journeyRepositoryV2.save(startJourneyDTO); const returnData: StartJourneyResDTOV2 = { journeyId: returnedData.journeyId, @@ -121,9 +135,9 @@ export class JourneyService { return returnData; } - async endV2(endJourneyDTO: EndJourneyReqDTOV2) { - const { coordinates, journeyId, song } = endJourneyDTO; - const originalData = await this.journeyRepository.findOne({ + async endV2(journeyId, endJourneyDTO: EndJourneyReqDTOV2) { + const { coordinates, song } = endJourneyDTO; + const originalData = await this.journeyRepositoryV2.findOne({ where: { journeyId }, }); if (!originalData) { @@ -132,13 +146,14 @@ export class JourneyService { const newCoordinates = `LINESTRING(${coordinates})`; const newJourneyData = { + journeyId, ...originalData, ...endJourneyDTO, song: JSON.stringify(song), coordinates: newCoordinates, }; - const returnedDate = await this.journeyRepository.save(newJourneyData); + const returnedDate = await this.journeyRepositoryV2.save(newJourneyData); const parsedCoordinates = parseCoordinatesFromGeoToDtoV2( returnedDate.coordinates, @@ -147,7 +162,7 @@ export class JourneyService { journeyId: returnedDate.journeyId, coordinates: parsedCoordinates, endTimestamp: returnedDate.endTimestamp, - numberOfCoordinates: parsedCoordinates.length, + numberOfCoordinates: parsedCoordinates.split(',').length, song: JSON.parse(returnedDate.song), }; @@ -204,16 +219,16 @@ export class JourneyService { xMaxCoordinate, yMaxCoordinate, }; - const returnedData = await this.journeyRepository.manager - .createQueryBuilder(Journey, 'journey') + const returnedData = await this.journeyRepositoryV2 + .createQueryBuilder('journey') .leftJoinAndSelect('journey.spots', 'spot') + .leftJoinAndSelect('spot.photos', 'photo') .where( `st_within(coordinates, ST_PolygonFromText('POLYGON((:xMinCoordinate :yMinCoordinate, :xMaxCoordinate :yMinCoordinate, :xMaxCoordinate :yMaxCoordinate, :xMinCoordinate :yMaxCoordinate, :xMinCoordinate :yMinCoordinate))'))`, coordinatesRange, ) .where('userId = :userId', { userId }) .getMany(); - console.log(returnedData); return returnedData.map((data) => { return this.parseJourneyFromEntityToDtoV2(data); }); @@ -251,7 +266,7 @@ export class JourneyService { }); } async getLastJourneyByUserIdV2(userId) { - const journeys = await this.journeyRepository.manager + const journeys = await this.journeyRepositoryV2.manager .createQueryBuilder(Journey, 'journey') .where({ userId }) .leftJoinAndSelect('journey.spots', 'spot') @@ -303,11 +318,10 @@ export class JourneyService { }; } async getJourneyByIdV2(journeyId) { - const returnedData = await this.journeyRepository.manager - .createQueryBuilder(Journey, 'journey') - .where({ journeyId }) - .leftJoinAndSelect('journey.spots', 'spot') - .getOne(); + const returnedData = await this.journeyRepositoryV2.findOne({ + where: { journeyId: journeyId }, + relations: ['spots', 'spots.photos'], + }); return this.parseJourneyFromEntityToDtoV2(returnedData); } async getJourneyById(journeyId) { @@ -341,7 +355,13 @@ export class JourneyService { return { ...spot, coordinate: parseCoordinateFromGeoToDtoV2(spot.coordinate), - photoUrl: makePresignedUrl(spot.photoKey), + spotSong: JSON.parse(spot.spotSong), + photos: spot.photos.map((photo) => { + return { + ...photo, + photoUrl: makePresignedUrl(photo.photoKey), + }; + }), }; }), }; @@ -379,4 +399,77 @@ export class JourneyService { const { journeyId } = deletedJourneyDto; return await this.journeyRepository.delete({ journeyId }); } + + async savePhotoToS3(files, spotId) { + const keys: string[] = []; + const promises = files.map(async (file, idx) => { + const key: string = `${spotId}/${Date.now()}${idx}`; + keys.push(key); + const reusult = await S3.putObject({ + Bucket: bucketName, + Key: key, + Body: file.buffer, + }).promise(); + }); + await Promise.all(promises); + return keys; + } + + async saveSpotDtoToSpot(journeyId, recordSpotDto) { + try { + const data = { + ...recordSpotDto, + journeyId: Number(journeyId), + coordinate: parseCoordinateFromDtoToGeoV2(recordSpotDto.coordinate), + }; + + return await this.spotRepositoryV2.save(data); + } catch (err) { + console.log(err); + } + } + + async savePhotoKeysToPhoto(spotId, keys) { + const data = keys.map((key) => { + return { + spotId, + photoKey: key, + }; + }); + return await this.photoRepository.save(data); + } + parseToSaveSpotResDtoFormat(spotResult, photoResult): RecordJourneyResDTO { + return { + spotId: spotResult.spotId, + ...spotResult, + spotSong: JSON.parse(spotResult.spotSong), + photos: photoResult.map((result) => { + return { + photoId: result.photoId, + photoUrl: makePresignedUrl(result.photoKey), + }; + }), + }; + } + + async saveSpot(files, journeyId, recordSpotDto) { + const saveSpotResult = await this.saveSpotDtoToSpot( + journeyId, + recordSpotDto, + ); + const keys: string[] = await this.savePhotoToS3( + files, + saveSpotResult.spotId, + ); + const photoSaveReuslt = await this.savePhotoKeysToPhoto( + saveSpotResult.spotId, + keys, + ); + + return this.parseToSaveSpotResDtoFormat(saveSpotResult, photoSaveReuslt); + } + async deleteJourney(journeyId: number){ + return this.journeyRepositoryV2.delete(journeyId); + } + } diff --git a/BE/musicspot/src/journey/service/test-image/test.png b/BE/musicspot/src/journey/service/test-image/test.png new file mode 100644 index 0000000..faf1b77 Binary files /dev/null and b/BE/musicspot/src/journey/service/test-image/test.png differ diff --git a/BE/musicspot/src/journey/service/test-image/test1.png b/BE/musicspot/src/journey/service/test-image/test1.png new file mode 100644 index 0000000..7ca25d5 Binary files /dev/null and b/BE/musicspot/src/journey/service/test-image/test1.png differ diff --git a/BE/musicspot/src/photo/controller/photo.controller.spec.ts b/BE/musicspot/src/photo/controller/photo.controller.spec.ts new file mode 100644 index 0000000..422ab28 --- /dev/null +++ b/BE/musicspot/src/photo/controller/photo.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PhotoController } from './photo.controller'; + +describe('PhotoController', () => { + let controller: PhotoController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PhotoController], + }).compile(); + + controller = module.get(PhotoController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/BE/musicspot/src/photo/controller/photo.controller.ts b/BE/musicspot/src/photo/controller/photo.controller.ts new file mode 100644 index 0000000..5b4f205 --- /dev/null +++ b/BE/musicspot/src/photo/controller/photo.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Delete, Param, Version } from '@nestjs/common'; +import { PhotoService } from '../service/photo.service'; +import {ApiCreatedResponse, ApiOperation, ApiTags} from '@nestjs/swagger'; +import { StartJourneyResDTO } from '../../journey/dto/journeyStart/journeyStart.dto'; + +@Controller('photo') +export class PhotoController { + constructor(private photoService: PhotoService) {} + + @ApiTags('Photo V2') + @Version('2') + @ApiOperation({ + summary: '사진 삭제 API', + description: '여정 기록을 시작합니다.', + }) + @ApiCreatedResponse({ + description: '생성된 여정 데이터를 반환', + type: StartJourneyResDTO, + }) + @Delete(':photoId') + async deletePhoto(@Param('photoId') photoId) { + return await this.photoService.deletePhoto(photoId); + } +} diff --git a/BE/musicspot/src/photo/dto/photo.dto.ts b/BE/musicspot/src/photo/dto/photo.dto.ts new file mode 100644 index 0000000..3baf563 --- /dev/null +++ b/BE/musicspot/src/photo/dto/photo.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PhotoDTO { + @ApiProperty({ + description: 'photo Id', + example: '20', + }) + readonly photoId: number; + + @ApiProperty({ + description: 'photo presigned url', + example: + 'https://music-spot-test.kr.object.ncloudstorage.com/52/17083469749660?AWSAccessKeyId=194C0D972294FBAFCE35&Expires=1708347035&Signature=29GAH%2Fl1BcsTkYof5BUqcXPRPVU%3D', + }) + readonly photoUrl: string; +} diff --git a/BE/musicspot/src/photo/entity/photo.entity.ts b/BE/musicspot/src/photo/entity/photo.entity.ts new file mode 100644 index 0000000..b6d72a9 --- /dev/null +++ b/BE/musicspot/src/photo/entity/photo.entity.ts @@ -0,0 +1,23 @@ +import { SpotV2 } from '../../spot/entities/spot.v2.entity'; +import { + Entity, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + Column, +} from 'typeorm'; +@Entity() +export class Photo { + @PrimaryGeneratedColumn() + photoId: number; + + @Column() + spotId: number; + + @Column() + photoKey: string; + + @ManyToOne(() => SpotV2, (spot) => spot.photos, {onDelete: 'CASCADE'}) + @JoinColumn({ name: 'spotId' }) + spot: SpotV2; +} diff --git a/BE/musicspot/src/photo/module/photo.module.ts b/BE/musicspot/src/photo/module/photo.module.ts new file mode 100644 index 0000000..8346b7e --- /dev/null +++ b/BE/musicspot/src/photo/module/photo.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PhotoController } from '../controller/photo.controller'; +import { PhotoService } from '../service/photo.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Photo } from '../entity/photo.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Photo])], + controllers: [PhotoController], + providers: [PhotoService], +}) +export class PhotoModule {} diff --git a/BE/musicspot/src/photo/photo.repository.ts b/BE/musicspot/src/photo/photo.repository.ts new file mode 100644 index 0000000..e4f7e83 --- /dev/null +++ b/BE/musicspot/src/photo/photo.repository.ts @@ -0,0 +1,6 @@ +import { CustomRepository } from '../common/decorator/customRepository.decorator'; +import { Repository } from 'typeorm'; +import { Photo } from './entity/photo.entity'; + +@CustomRepository(Photo) +export class PhotoRepository extends Repository {} diff --git a/BE/musicspot/src/photo/service/photo.service.spec.ts b/BE/musicspot/src/photo/service/photo.service.spec.ts new file mode 100644 index 0000000..9297582 --- /dev/null +++ b/BE/musicspot/src/photo/service/photo.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PhotoService } from './photo.service'; + +describe('PhotoService', () => { + let service: PhotoService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PhotoService], + }).compile(); + + service = module.get(PhotoService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/BE/musicspot/src/photo/service/photo.service.ts b/BE/musicspot/src/photo/service/photo.service.ts new file mode 100644 index 0000000..1e6d69c --- /dev/null +++ b/BE/musicspot/src/photo/service/photo.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Photo } from '../entity/photo.entity'; +import { Repository } from 'typeorm'; + +@Injectable() +export class PhotoService { + constructor( + @InjectRepository(Photo) private photoRepository: Repository, + ) {} + + async deletePhoto(photoId) { + return await this.photoRepository.delete(photoId); + } +} diff --git a/BE/musicspot/src/spot/controller/spot.controller.ts b/BE/musicspot/src/spot/controller/spot.controller.ts index 830a96d..8e3673c 100644 --- a/BE/musicspot/src/spot/controller/spot.controller.ts +++ b/BE/musicspot/src/spot/controller/spot.controller.ts @@ -7,19 +7,22 @@ import { Get, Query, Version, + Param, + UploadedFiles, } from '@nestjs/common'; import { ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RecordSpotReqDTO } from '../dto/recordSpot.dto'; import { SpotService } from '../service/spot.service'; -import { FileInterceptor } from '@nestjs/platform-express'; +import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { Spot } from '../schema/spot.schema'; import { SpotDTO } from 'src/journey/dto/journeyCheck/journeyCheck.dto'; -import {RecordSpotReqDTOV2} from "../dto/v2/recordSpot.v2.dto"; +import { RecordSpotReqDTOV2 } from '../dto/v2/recordSpot.v2.dto'; +import { Photo } from '../../photo/entity/photo.entity'; @Controller('spot') -@ApiTags('spot 관련 API') export class SpotController { constructor(private spotService: SpotService) {} + @ApiTags('Spot V1') @ApiOperation({ summary: 'spot 기록 API', description: 'spot을 기록합니다.', @@ -37,23 +40,29 @@ export class SpotController { return await this.spotService.create(file, recordSpotDTO); } - @Version('2') - @ApiOperation({ - summary: 'spot 기록 API', - description: 'spot을 기록합니다.', - }) - @ApiCreatedResponse({ - description: 'spot 생성 데이터 반환', - type: SpotDTO, - }) - @UseInterceptors(FileInterceptor('image')) - @Post('') - async createV2( - @UploadedFile() file: Express.Multer.File, - @Body() recordSpotDTO, - ) { - return await this.spotService.createV2(file, recordSpotDTO); - } + // @Version('2') + // @ApiOperation({ + // summary: 'photo 저장 api', + // description: 'photo를 기록합니다.', + // }) + // @ApiCreatedResponse({ + // description: 'photo 생성 데이터 반환', + // type: Photo, + // }) + // @UseInterceptors(FilesInterceptor('images')) + // @Post(':spotId/photo') + // async savePhoto( + // @UploadedFiles() images: Array, + // @Param('spotId') spotId: number, + // ) { + // try { + // return this.spotService.savePhoto(images, spotId); + // } catch (err) { + // console.log(err); + // } + // } + + @ApiTags('Spot V1') @ApiOperation({ summary: 'spot 조회 API', description: 'spotId로 스팟 이미지를 조회합니다.', @@ -71,6 +80,15 @@ export class SpotController { } } + // @ApiOperation({ + // summary : 'spot에 photo 추가', + // t + // }) + // @Post(":spotId/photo") + // async insertPhotoToSpot(@Param('spotId') spotId: number){ + // + // } + // @Post() // async create(@Body() recordSpotDTO: RecordSpotDTO) { // return await this.spotService.create(recordSpotDTO); diff --git a/BE/musicspot/src/spot/dto/v2/insertPhoto.dto.ts b/BE/musicspot/src/spot/dto/v2/insertPhoto.dto.ts new file mode 100644 index 0000000..8724d20 --- /dev/null +++ b/BE/musicspot/src/spot/dto/v2/insertPhoto.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsNumber } from 'class-validator'; +import { IsCoordinateV2 } from '../../../common/decorator/coordinate.v2.decorator'; + +export class InsertPhoto { + @ApiProperty({ + example: '20', + description: '스팟 id', + required: true, + }) + @IsNumber() + readonly journeyId: number; + + @ApiProperty({ + example: '37.555946 126.972384', + description: '위치 좌표', + required: true, + }) + @IsCoordinateV2({ + message: + '위치 좌표는 2개의 숫자와 각각의 범위를 만족해야합니다.(-90~90 , -180~180)', + }) + readonly coordinate: string; + + @ApiProperty({ + example: '2023-11-22T12:00:00Z', + description: 'timestamp', + required: true, + }) + @IsDateString() + readonly timestamp: string; +} + +export class RecordSpotResDTOV2 { + @ApiProperty({ + example: 20, + description: '여정 id', + required: true, + }) + readonly journeyId: number; + + @ApiProperty({ + example: '37.555946 126.972384', + description: '위치 좌표', + required: true, + }) + readonly coordinate: string; + + @ApiProperty({ + example: '2023-11-22T12:00:00Z', + description: 'timestamp', + required: true, + }) + readonly timestamp: string; + + @ApiProperty({ + example: + 'https://music-spot-storage.kr.object.ncloudstorage.com/path/name?AWSAccessKeyId=key&Expires=1701850233&Signature=signature', + description: 'presigned url', + required: true, + }) + readonly photoUrl: string; +} diff --git a/BE/musicspot/src/spot/dto/v2/recordSpot.v2.dto.ts b/BE/musicspot/src/spot/dto/v2/recordSpot.v2.dto.ts index a506726..8d6be29 100644 --- a/BE/musicspot/src/spot/dto/v2/recordSpot.v2.dto.ts +++ b/BE/musicspot/src/spot/dto/v2/recordSpot.v2.dto.ts @@ -1,16 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString, IsNumber } from 'class-validator'; +import { IsDateString, IsString } from 'class-validator'; import { IsCoordinateV2 } from '../../../common/decorator/coordinate.v2.decorator'; +import { SongDTO } from '../../../journey/dto/song/song.dto'; export class RecordSpotReqDTOV2 { - @ApiProperty({ - example: '20', - description: '여정 id', - required: true, - }) - @IsNumber() - readonly journeyId: number; - @ApiProperty({ example: '37.555946 126.972384', description: '위치 좌표', @@ -29,9 +22,43 @@ export class RecordSpotReqDTOV2 { }) @IsDateString() readonly timestamp: string; + + @IsString() + readonly spotSong: string; } +const spotSongEx = { + id: '655efda2fdc81cae36d20650', + name: 'super shy', + artistName: 'newjeans', + artwork: { + width: 3000, + height: 3000, + url: 'https://is3-ssl.mzstatic.com/image/thumb/Music125/v4/0b/b2/52/0bb2524d-ecfc-1bae-9c1e-218c978d7072/Honeymoon_3K.jpg/{w}x{h}bb.jpg', + bgColor: '3000', + }, +}; +export class Photo { + @ApiProperty({ + description: 'photo Id', + example: '20', + }) + readonly photoId: number; + + @ApiProperty({ + description: 'photo presigned url', + example: + 'https://music-spot-test.kr.object.ncloudstorage.com/52/17083469749660?AWSAccessKeyId=194C0D972294FBAFCE35&Expires=1708347035&Signature=29GAH%2Fl1BcsTkYof5BUqcXPRPVU%3D', + }) + readonly photoUrl: string; +} export class RecordSpotResDTOV2 { + @ApiProperty({ + example: 20, + description: 'spot id', + }) + readonly spotId: number; + @ApiProperty({ example: 20, description: '여정 id', @@ -56,8 +83,20 @@ export class RecordSpotResDTOV2 { @ApiProperty({ example: 'https://music-spot-storage.kr.object.ncloudstorage.com/path/name?AWSAccessKeyId=key&Expires=1701850233&Signature=signature', - description: 'presigned url', - required: true, + description: 'photo key(V1만 유효)', }) readonly photoUrl: string; + + @ApiProperty({ + description: 'spot 별 음악(V2)', + type: SongDTO, + }) + readonly spotSong; + + @ApiProperty({ + description: 'photo의 url 모음', + type: Photo, + isArray: true, + }) + readonly photos: Photo[]; } diff --git a/BE/musicspot/src/spot/dto/v2/spot.dto.ts b/BE/musicspot/src/spot/dto/v2/spot.dto.ts new file mode 100644 index 0000000..56bf717 --- /dev/null +++ b/BE/musicspot/src/spot/dto/v2/spot.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SongDTO } from '../../../journey/dto/song/song.dto'; +import { PhotoDTO } from '../../../photo/dto/photo.dto'; + +export class SpotV2DTO { + @ApiProperty({ + example: 20, + description: 'spot id', + }) + readonly spotId: number; + + @ApiProperty({ + example: 20, + description: '여정 id', + required: true, + }) + readonly journeyId: number; + + @ApiProperty({ + example: '37.555946 126.972384', + description: '위치 좌표', + required: true, + }) + readonly coordinate: string; + + @ApiProperty({ + example: '2023-11-22T12:00:00Z', + description: 'timestamp', + required: true, + }) + readonly timestamp: string; + + @ApiProperty({ + example: null, + description: 'photo key(V1만 유효)', + }) + readonly photoKey: string; + + @ApiProperty({ + description: 'spot 별 음악(V2)', + type: SongDTO, + }) + readonly spotSong; + + @ApiProperty({ + description: 'photo의 url 모음', + type: PhotoDTO, + isArray: true, + }) + readonly photos: PhotoDTO[]; +} diff --git a/BE/musicspot/src/spot/entities/spot.entity.ts b/BE/musicspot/src/spot/entities/spot.entity.ts index 6d77308..f633847 100644 --- a/BE/musicspot/src/spot/entities/spot.entity.ts +++ b/BE/musicspot/src/spot/entities/spot.entity.ts @@ -1,29 +1,30 @@ - -import { Journey } from "src/journey/entities/journey.entity"; -import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from "typeorm"; +import { Journey } from '../../journey/entities/journey.entity'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; @Entity() -export class Spot{ - @PrimaryGeneratedColumn() - spotId: number; - - @Column() - journeyId : number; +export class Spot { + @PrimaryGeneratedColumn() + spotId: number; - @Column("geometry") - coordinate: string; + @Column() + journeyId: number; - @Column() - timestamp: string; + @Column('geometry') + coordinate: string; - @Column() - photoKey : string; + @Column() + timestamp: string; - - - @ManyToOne(()=>Journey, (journey) => journey.spots) - @JoinColumn({name: "journeyId"}) - journey : Journey + @Column() + photoKey: string; + @ManyToOne(() => Journey, (journey) => journey.spots) + @JoinColumn({ name: 'journeyId' }) + journey: Journey; } - diff --git a/BE/musicspot/src/spot/entities/spot.v2.entity.ts b/BE/musicspot/src/spot/entities/spot.v2.entity.ts new file mode 100644 index 0000000..eb5cca3 --- /dev/null +++ b/BE/musicspot/src/spot/entities/spot.v2.entity.ts @@ -0,0 +1,41 @@ +import { Journey } from '../../journey/entities/journey.entity'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { Photo } from '../../photo/entity/photo.entity'; +import {JourneyV2} from "../../journey/entities/journey.v2.entity"; + +@Entity({ name: 'spot' }) +export class SpotV2 { + @PrimaryGeneratedColumn() + spotId: number; + + @Column() + journeyId: number; + + @Column('geometry') + coordinate: string; + + @Column() + timestamp: string; + + @Column({ + nullable: true, + }) + photoKey: string; + + @Column() + spotSong: string; + + @ManyToOne(() => JourneyV2, (journey) => journey.spots, { onDelete: 'CASCADE'}) + @JoinColumn({ name: 'journeyId', referencedColumnName:'journeyId' }) + journey: JourneyV2; + + @OneToMany(() => Photo, (photo) => photo.spot) + photos: Photo[]; +} diff --git a/BE/musicspot/src/spot/module/spot.module.ts b/BE/musicspot/src/spot/module/spot.module.ts index a2f5daa..6c58f82 100644 --- a/BE/musicspot/src/spot/module/spot.module.ts +++ b/BE/musicspot/src/spot/module/spot.module.ts @@ -7,14 +7,17 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { SpotRepository } from '../repository/spot.repository'; import { TypeOrmExModule } from 'src/dynamic.module'; import { JourneyRepository } from 'src/journey/repository/journey.repository'; +import { Journey } from '../../journey/entities/journey.entity'; +import {Photo} from "../../photo/entity/photo.entity"; // import { Journey, JourneySchema } from 'src/journey/schema/journey.schema'; @Module({ imports: [ - TypeOrmExModule.forFeature([SpotRepository, JourneyRepository]) + // TypeOrmExModule.forFeature([SpotRepository, JourneyRepository]) // MongooseModule.forFeature([ // { name: Spot.name, schema: SpotSchema }, // { name: Journey.name, schema: JourneySchema }, // ]), + TypeOrmModule.forFeature([Spot, Journey, Photo]), ], controllers: [SpotController], providers: [SpotService], diff --git a/BE/musicspot/src/spot/repository/spot.repository.ts b/BE/musicspot/src/spot/repository/spot.repository.ts index 7375f31..c63b97c 100644 --- a/BE/musicspot/src/spot/repository/spot.repository.ts +++ b/BE/musicspot/src/spot/repository/spot.repository.ts @@ -1,6 +1,6 @@ -import { CustomRepository } from "src/common/decorator/customRepository.decorator"; -import { Repository } from "typeorm"; -import { Spot } from "../entities/spot.entity"; +import { CustomRepository } from '../../common/decorator/customRepository.decorator'; +import { Repository } from 'typeorm'; +import { Spot } from '../entities/spot.entity'; @CustomRepository(Spot) -export class SpotRepository extends Repository{}; \ No newline at end of file +export class SpotRepository extends Repository {} diff --git a/BE/musicspot/src/spot/service/spot.service.spec.ts b/BE/musicspot/src/spot/service/spot.service.spec.ts index 6cfe8ec..827fa28 100644 --- a/BE/musicspot/src/spot/service/spot.service.spec.ts +++ b/BE/musicspot/src/spot/service/spot.service.spec.ts @@ -2,53 +2,19 @@ import * as fs from 'fs'; import * as path from 'path'; import { Test, TestingModule } from '@nestjs/testing'; import { SpotService } from './spot.service'; -import mongoose from 'mongoose'; -import { Spot, SpotSchema } from '../schema/spot.schema'; -import { getModelToken } from '@nestjs/mongoose'; - -import { Journey, JourneySchema } from '../../journey/schema/journey.schema'; -import { ConfigBase } from 'aws-sdk/lib/config'; -describe('SpotService', () => { - let service: SpotService; - let spotModel; - let journeyModel; +import {RecordSpotReqDTOV2} from "../dto/v2/recordSpot.v2.dto"; +describe.skip('SpotService', () => { + let service; beforeAll(async () => { - mongoose.connect('mongodb://192.168.174.128:27017/musicspotDB'); - spotModel = mongoose.model(Spot.name, SpotSchema); - journeyModel = mongoose.model(Journey.name, JourneySchema); const module: TestingModule = await Test.createTestingModule({ - providers: [ - SpotService, - { - provide: getModelToken(Spot.name), - useValue: spotModel, - }, - { provide: getModelToken(Journey.name), useValue: journeyModel }, - ], + providers: [SpotService], }).compile(); service = module.get(SpotService); }); - it('spot 삽입 테스트', async () => { - const imagePath = path.join(__dirname, 'test/test.png'); - - // 이미지 파일 동기적으로 읽기 - const imageBuffer = fs.readFileSync(imagePath); - - const journeyId = '655b6d6bfd9f60fc689789a6'; - const data = { - journeyId, - coordinate: [10, 10], - timestamp: '1시50분', - photo: imageBuffer, - }; - const photoUrl = await service.uploadPhotoToStorage(data.photo); - const createdSpotData = await service.insertToSpot({ ...data, photoUrl }); - expect(createdSpotData.journeyId).toEqual(journeyId); - }); + it('spot 저장 테스트', () => { + const insertData: RecordSpotReqDTOV2 ={} - afterAll(() => { - mongoose.connection.close(); }); }); diff --git a/BE/musicspot/src/spot/service/spot.service.ts b/BE/musicspot/src/spot/service/spot.service.ts index 69bf732..72321b1 100644 --- a/BE/musicspot/src/spot/service/spot.service.ts +++ b/BE/musicspot/src/spot/service/spot.service.ts @@ -16,7 +16,8 @@ import { } from 'src/filters/journey.exception'; import { is1DArray, - parseCoordinateFromDtoToGeo, parseCoordinateFromGeoToDto, + parseCoordinateFromDtoToGeo, + parseCoordinateFromGeoToDto, } from 'src/common/util/coordinate.util'; import { SpotRepository } from '../repository/spot.repository'; import { RecordSpotResDTO } from '../dto/recordSpot.dto'; @@ -27,14 +28,22 @@ import { } from '../dto/v2/recordSpot.v2.dto'; import { isPointString, - parseCoordinateFromDtoToGeoV2, parseCoordinateFromGeoToDtoV2, + parseCoordinateFromDtoToGeoV2, + parseCoordinateFromGeoToDtoV2, } from '../../common/util/coordinate.v2.util'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Journey } from '../../journey/entities/journey.entity'; +import { Repository } from 'typeorm'; +import { Photo } from '../../photo/entity/photo.entity'; @Injectable() export class SpotService { constructor( - private spotRepository: SpotRepository, - private journeyRepository: JourneyRepository, + // private spotRepository: SpotRepository, + // private journeyRepository: JourneyRepository, + @InjectRepository(Journey) private journeyRepository: Repository, + @InjectRepository(Spot) private spotRepository: Repository, + @InjectRepository(Photo) private photoRepository: Repository, ) {} async uploadPhotoToStorage(journeyId, file) { @@ -51,7 +60,25 @@ export class SpotService { throw new SpotRecordFail(); } } - + async uploadPhotoToStorageV2(journeyId, files) { + const keys: string[] = []; + try { + const promises = files.map(async (file, idx) => { + const key = `${journeyId}/${Date.now()}${idx}`; + keys.push(key); + const result = await S3.putObject({ + Bucket: bucketName, + Key: key, + Body: file.buffer, + }).promise(); + }); + await Promise.all(promises); + // return key; + return keys; + } catch (err) { + throw new SpotRecordFail(); + } + } async insertToSpotV2(spotData) { const data = { ...spotData, @@ -89,31 +116,32 @@ export class SpotService { return await this.journeyRepository.save(originalJourney); } - async createV2(file, recordSpotDto) { + async createV2(files, recordSpotDto) { const { coordinate } = recordSpotDto; if (!isPointString(coordinate)) { throw new coordinateNotCorrectException(); } - - const photoKey: string = await this.uploadPhotoToStorage( + const keys: string[] = await this.uploadPhotoToStorageV2( recordSpotDto.journeyId, - file, + files, ); - const presignedUrl: string = makePresignedUrl(photoKey); - const createdSpotData = await this.insertToSpotV2({ - ...recordSpotDto, - photoKey, - coordinate: parseCoordinateFromDtoToGeoV2(coordinate), - }); - - const returnData: RecordSpotResDTOV2 = { - journeyId: createdSpotData.journeyId, - coordinate: parseCoordinateFromGeoToDtoV2(createdSpotData.coordinate), - timestamp: createdSpotData.timestamp, - photoUrl: presignedUrl, - }; - return returnData; + // const presignedUrls:string[] = keys.map(key=> makePresignedUrl(key)) + + // const createdSpotData = await this.insertToSpotV2({ + // ...recordSpotDto, + // photoKey, + // coordinate: parseCoordinateFromDtoToGeoV2(coordinate), + // }); + // + // // const returnData: RecordSpotResDTOV2 = { + ``; // // journeyId: createdSpotData.journeyId, + // // coordinate: parseCoordinateFromGeoToDtoV2(createdSpotData.coordinate), + // // timestamp: createdSpotData.timestamp, + // // photoUrl: presignedUrl, + // // }; + // + // return returnData; } async create(file, recordSpotDto) { let parsedCoordinate; @@ -160,4 +188,33 @@ export class SpotService { return spot.photoKey; } + + async savePhotoToS3(files, spotId: number) { + const keys: string[] = []; + const promises = files.map(async (file, idx) => { + const key: string = `${spotId}/${Date.now()}${idx}`; + keys.push(key); + const reusult = await S3.putObject({ + Bucket: bucketName, + Key: key, + Body: file.buffer, + }).promise(); + }); + await Promise.all(promises); + return keys; + } + async savePhotoKeysToPhoto(spotId, keys) { + const data = keys.map((key) => { + return { + spotId, + photoKey: key, + }; + }); + return await this.photoRepository.save(data); + } + + async savePhoto(files, spotId) { + const keys = await this.savePhotoToS3(files, spotId); + return await this.savePhotoKeysToPhoto(spotId, keys); + } } diff --git a/BE/musicspot/src/user/controller/user.controller.ts b/BE/musicspot/src/user/controller/user.controller.ts index 8fd5744..f5232b4 100644 --- a/BE/musicspot/src/user/controller/user.controller.ts +++ b/BE/musicspot/src/user/controller/user.controller.ts @@ -1,14 +1,40 @@ -import { Controller, Body, Post } from '@nestjs/common'; +import { + Controller, + Body, + Post, + Param, + Version, + Get, + UsePipes, + ValidationPipe, + Query, +} from '@nestjs/common'; import { UserService } from '../serivce/user.service'; import { CreateUserDTO } from '../dto/createUser.dto'; -import { ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiCreatedResponse, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; import { User } from '../entities/user.entity'; +import { + StartJourneyRequestDTOV2, + StartJourneyResponseDTOV2, +} from '../dto/startJourney.dto'; +import { Journey } from '../../journey/entities/journey.entity'; +import { CheckJourneyResDTO } from '../../journey/dto/journeyCheck/journeyCheck.dto'; +import { UUID } from 'crypto'; +import { LastJourneyResDTO } from '../../journey/dto/journeyLast.dto'; +import { StartJourneyResDTO } from '../../journey/dto/journeyStart/journeyStart.dto'; +import { JourneyV2DTO } from '../../journey/dto/v2/jounrey.dto'; +import {LastJourneyResV2DTO} from "../../journey/dto/v2/lastJourney.dto.v2"; @Controller('user') -@ApiTags('user 관련 API') export class UserController { constructor(private userService: UserService) {} + @ApiTags('User V1') @ApiOperation({ summary: '유저 생성 API', description: '첫 시작 시 유저를 생성합니다.', @@ -21,4 +47,89 @@ export class UserController { async create(@Body() createUserDto: CreateUserDTO): Promise { return await this.userService.create(createUserDto); } + @ApiTags('Journey V2') + @Version('2') + @ApiOperation({ + summary: '여정 시작 API', + description: '여정을 시작합니다.', + }) + @ApiCreatedResponse({ + description: '생성된 여정 데이터를 반환', + type: StartJourneyResponseDTOV2, + }) + @Post(':userId/journey/start') + async startJourney( + @Param('userId') userId: string, + @Body() startJourneyDto: StartJourneyRequestDTOV2, + ) { + return await this.userService.startJourney(userId, startJourneyDto); + } + + @ApiTags('Journey V2') + @Version('2') + @ApiOperation({ + summary: '여정 조회 API(Coordiante 범위)', + description: '해당 범위 내의 여정들을 반환합니다.', + }) + @ApiQuery({ + name: 'userId', + description: '유저 ID', + required: true, + example: 'yourUserId', + }) + @ApiQuery({ + name: 'minCoordinate', + description: '최소 좌표', + required: true, + example: '37.5 127.0', + }) + @ApiQuery({ + name: 'maxCoordinate', + description: '최대 좌표', + required: true, + example: '38.0 128.0', + }) + @ApiCreatedResponse({ + description: '범위에 있는 여정의 기록들을 반환', + type: JourneyV2DTO, + }) + @Get(':userId/journey') + @UsePipes(ValidationPipe) + async getJourneyByCoordinate( + @Param('userId') userId: UUID, + @Query('minCoordinate') minCoordinate: string, + @Query('maxCoordinate') maxCoordinate: string, + ) { + console.log('min:', minCoordinate, 'max:', maxCoordinate); + try { + const checkJourneyDTO = { + userId, + minCoordinate, + maxCoordinate, + }; + return await this.userService.getJourneyByCoordinationRangeV2( + checkJourneyDTO, + ); + } catch (err) { + console.log(err); + } + } + @ApiTags('Journey V2') + @Version('2') + @ApiOperation({ + summary: '마지막 여정 진행 중 여부 확인 API', + description: '진행 중인 여정이 있었는 지 확인', + }) + @ApiCreatedResponse({ + description: '사용자가 진행중이었던 여정 정보', + type: LastJourneyResV2DTO, + }) + @Get(':userId/journey/last') + async loadLastDataV2(@Param('userId') userId: UUID) { + try { + return await this.userService.getLastJourneyByUserIdV2(userId); + } catch (err) { + console.log(err); + } + } } diff --git a/BE/musicspot/src/user/dto/startJourney.dto.ts b/BE/musicspot/src/user/dto/startJourney.dto.ts new file mode 100644 index 0000000..08fd4c2 --- /dev/null +++ b/BE/musicspot/src/user/dto/startJourney.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsUUID } from 'class-validator'; +import { UUID } from 'crypto'; + +export class StartJourneyRequestDTOV2 { + @ApiProperty({ + example: '2023-11-22T12:00:00Z', + description: '시작 timestamp', + required: true, + }) + @IsDateString() + readonly startTimestamp: string; +} + +export class StartJourneyResponseDTOV2 { + @ApiProperty({ + example: '0f8fad5b-d9cb-469f-a165-708677289501', + description: 'user id', + }) + readonly userId: UUID; + + @ApiProperty({ + example: 20, + description: '저장한 journey id', + }) + readonly journeyId: number; + + @ApiProperty({ + example: '2023-11-22T12:00:00Z', + description: 'timestamp', + }) + readonly startTimestamp: string; +} diff --git a/BE/musicspot/src/user/module/user.module.ts b/BE/musicspot/src/user/module/user.module.ts index 11a9401..f319cd8 100644 --- a/BE/musicspot/src/user/module/user.module.ts +++ b/BE/musicspot/src/user/module/user.module.ts @@ -6,11 +6,15 @@ import { MongooseModule } from '@nestjs/mongoose'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserRepository } from '../repository/user.repository'; import { TypeOrmExModule } from 'src/dynamic.module'; +import { User } from '../entities/user.entity'; +import {Journey} from "../../journey/entities/journey.entity"; +import {JourneyV2} from "../../journey/entities/journey.v2.entity"; @Module({ imports: [ - TypeOrmExModule.forFeature([UserRepository]) + // TypeOrmExModule.forFeature([UserRepository]) // MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), + TypeOrmModule.forFeature([User, JourneyV2]), ], controllers: [UserController], providers: [UserService], diff --git a/BE/musicspot/src/user/repository/user.repository.ts b/BE/musicspot/src/user/repository/user.repository.ts index ccfacfc..26767cb 100644 --- a/BE/musicspot/src/user/repository/user.repository.ts +++ b/BE/musicspot/src/user/repository/user.repository.ts @@ -1,8 +1,6 @@ -import { CustomRepository } from "src/common/decorator/customRepository.decorator"; -import { User } from "../entities/user.entity"; -import { Repository } from "typeorm"; -import { CreateUserDTO } from "../dto/createUser.dto"; +import { CustomRepository } from '../../common/decorator/customRepository.decorator'; +import { User } from '../entities/user.entity'; +import { Repository } from 'typeorm'; @CustomRepository(User) -export class UserRepository extends Repository{ -}; \ No newline at end of file +export class UserRepository extends Repository {} diff --git a/BE/musicspot/src/user/serivce/user.service.ts b/BE/musicspot/src/user/serivce/user.service.ts index 74f3ef2..d342927 100644 --- a/BE/musicspot/src/user/serivce/user.service.ts +++ b/BE/musicspot/src/user/serivce/user.service.ts @@ -1,11 +1,30 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Version } from '@nestjs/common'; import { Model } from 'mongoose'; import { InjectModel } from '@nestjs/mongoose'; // import { User } from '../schema/user.schema'; import { UUID } from 'crypto'; import { CreateUserDTO } from '../dto/createUser.dto'; -import { UserAlreadyExistException } from '../../filters/user.exception'; +import { + UserAlreadyExistException, + UserNotFoundException, +} from '../../filters/user.exception'; import { UserRepository } from '../repository/user.repository'; +import { InjectRepository } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import {Not, Repository, IsNull} from 'typeorm'; +import { Journey } from '../../journey/entities/journey.entity'; +import { JourneyV2 } from '../../journey/entities/journey.v2.entity'; +import { + StartJourneyRequestDTOV2, + StartJourneyResponseDTOV2, +} from '../dto/startJourney.dto'; +import { + isPointString, + parseCoordinateFromGeoToDtoV2, + parseCoordinatesFromGeoToDtoV2, +} from '../../common/util/coordinate.v2.util'; +import { coordinateNotCorrectException } from '../../filters/journey.exception'; +import { makePresignedUrl } from '../../common/s3/objectStorage'; // @Injectable() // export class UserService { // constructor(@InjectModel(User.name) private userModel: Model) {} @@ -37,17 +56,138 @@ import { UserRepository } from '../repository/user.repository'; // // } // } - @Injectable() -export class UserService{ - constructor(private userRepository: UserRepository){} - - async create(createUserDto: CreateUserDTO):Promise{ - const {userId} = createUserDto - if(await this.userRepository.findOne({where:{userId}})){ +export class UserService { + // constructor(private userRepository: UserRepository){} + constructor( + @InjectRepository(User) private userRepository: Repository, + @InjectRepository(JourneyV2) + private journeyRepositoryV2: Repository, + ) {} + async create( + createUserDto: CreateUserDTO, + ): Promise { + const { userId } = createUserDto; + if (await this.userRepository.findOne({ where: { userId } })) { throw new UserAlreadyExistException(); } return await this.userRepository.save(createUserDto); } -} \ No newline at end of file + async startJourney(userId, startJourneyDto: StartJourneyRequestDTOV2) { + if (!(await this.userRepository.findOne({ where: { userId } }))) { + throw new UserNotFoundException(); + } + + const journeyData = { + userId, + ...startJourneyDto, + }; + return await this.journeyRepositoryV2.save(journeyData); + } + async getJourneyByCoordinationRangeV2(checkJourneyDTO) { + const { userId, minCoordinate, maxCoordinate } = checkJourneyDTO; + if (!(await this.userRepository.findOne({ where: { userId } }))) { + throw new UserNotFoundException(); + } + + if (!(isPointString(minCoordinate) && isPointString(maxCoordinate))) { + throw new coordinateNotCorrectException(); + } + + const [xMinCoordinate, yMinCoordinate] = minCoordinate + .split(' ') + .map((str) => Number(str)); + const [xMaxCoordinate, yMaxCoordinate] = maxCoordinate + .split(' ') + .map((str) => Number(str)); + console.log(xMinCoordinate, yMinCoordinate, xMaxCoordinate, yMaxCoordinate); + const coordinatesRange = { + xMinCoordinate, + yMinCoordinate, + xMaxCoordinate, + yMaxCoordinate, + }; + const returnedData = await this.journeyRepositoryV2 + .createQueryBuilder('journey') + .leftJoinAndSelect('journey.spots', 'spot') + .leftJoinAndSelect('spot.photos', 'photo') + .where( + `st_within(coordinates, ST_PolygonFromText('POLYGON((:xMinCoordinate :yMinCoordinate, :xMaxCoordinate :yMinCoordinate, :xMaxCoordinate :yMaxCoordinate, :xMinCoordinate :yMaxCoordinate, :xMinCoordinate :yMinCoordinate))'))`, + coordinatesRange, + ) + .where('userId = :userId', { userId }) + .andWhere('journey.title is not null') + .getMany(); + console.log(returnedData); + return returnedData.map((data) => { + return this.parseJourneyFromEntityToDtoV2(data); + }); + } + + parseJourneyFromEntityToDtoV2(journey) { + const { + journeyId, + coordinates, + startTimestamp, + endTimestamp, + song, + title, + spots, + } = journey; + return { + journeyId, + coordinates: parseCoordinatesFromGeoToDtoV2(coordinates), + title, + journeyMetadata: { + startTimestamp, + endTimestamp, + }, + song: JSON.parse(song), + spots: spots.map((spot) => { + return { + ...spot, + coordinate: parseCoordinateFromGeoToDtoV2(spot.coordinate), + spotSong: JSON.parse(spot.spotSong), + photos: spot.photos.map((photo) => { + return { + ...photo, + photoUrl: makePresignedUrl(photo.photoKey), + }; + }), + }; + }), + }; + } + async getLastJourneyByUserIdV2(userId) { + // const journeys = await this.journeyRepositoryV2 + // .createQueryBuilder('journey') + // .where({ userId }) + // .leftJoinAndSelect('journey.spots', 'spot') + // .leftJoinAndSelect('spot.photos', 'photo') + // .getMany(); + const journey = await this.journeyRepositoryV2.findOne({ + order: { journeyId: 'DESC' }, + where: { userId }, + relations: ['spots', 'spots.photos'], + }); + if (!journey) { + return { + journey: null, + isRecording: false, + }; + } + + // const journeyLen = journeys.length; + // const lastJourneyData = journeys[journeyLen - 1]; + + if (journey.title) { + return { journey: null, isRecording: false }; + } + + return { + journey, + isRecording: true, + }; + } +}