diff --git a/backend/resources/sql/DDL.sql b/backend/resources/sql/DDL.sql index 24b3ee04..e3f1e2dc 100644 --- a/backend/resources/sql/DDL.sql +++ b/backend/resources/sql/DDL.sql @@ -64,7 +64,7 @@ CREATE TABLE MAP_PLACE ( id INT PRIMARY KEY AUTO_INCREMENT, place_id INT NOT NULL, - map_id INT NOT NULL, + map_id INT, description TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -94,7 +94,7 @@ CREATE TABLE COURSE_PLACE id INT PRIMARY KEY AUTO_INCREMENT, `order` INT NOT NULL, place_id INT NOT NULL, - course_id INT NOT NULL, + course_id INT, description TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/backend/resources/sql/Mock.sql b/backend/resources/sql/Mock.sql new file mode 100644 index 00000000..6056cef1 --- /dev/null +++ b/backend/resources/sql/Mock.sql @@ -0,0 +1,89 @@ +-- USER 데이터 삽입 +INSERT INTO USER (provider, nickname, oauth_id, role, profile_image_url) +VALUES ('google', 'user1', 'oauth1', 'USER', 'https://example.com/profile1.jpg'), + ('google', 'user2', 'oauth2', 'USER', 'https://example.com/profile2.jpg'), + ('google', 'user3', 'oauth3', 'USER', 'https://example.com/profile3.jpg'), + ('google', 'user4', 'oauth4', 'USER', 'https://example.com/profile4.jpg'), + ('google', 'user5', 'oauth5', 'USER', 'https://example.com/profile5.jpg'); + +-- PLACE 데이터 삽입 +INSERT INTO PLACE (google_place_id, name, thumbnail_url, rating, longitude, latitude, formatted_address, description, + detail_page_url) +VALUES ('place1', 'Place 1', 'https://example.com/place1.jpg', 4.5, 127.001, 37.501, 'Seoul, South Korea', + 'Beautiful place 1', 'https://example.com/detail1'), + ('place2', 'Place 2', 'https://example.com/place2.jpg', 4.0, 127.002, 37.502, 'Seoul, South Korea', + 'Beautiful place 2', 'https://example.com/detail2'), + ('place3', 'Place 3', 'https://example.com/place3.jpg', 4.2, 127.003, 37.503, 'Seoul, South Korea', + 'Beautiful place 3', 'https://example.com/detail3'), + ('place4', 'Place 4', 'https://example.com/place4.jpg', 4.1, 127.004, 37.504, 'Seoul, South Korea', + 'Beautiful place 4', 'https://example.com/detail4'), + ('place5', 'Place 5', 'https://example.com/place5.jpg', 3.9, 127.005, 37.505, 'Seoul, South Korea', + 'Beautiful place 5', 'https://example.com/detail5'), + ('place6', 'Place 6', 'https://example.com/place6.jpg', 4.3, 127.006, 37.506, 'Seoul, South Korea', + 'Beautiful place 6', 'https://example.com/detail6'), + ('place7', 'Place 7', 'https://example.com/place7.jpg', 4.4, 127.007, 37.507, 'Seoul, South Korea', + 'Beautiful place 7', 'https://example.com/detail7'), + ('place8', 'Place 8', 'https://example.com/place8.jpg', 4.6, 127.008, 37.508, 'Seoul, South Korea', + 'Beautiful place 8', 'https://example.com/detail8'), + ('place9', 'Place 9', 'https://example.com/place9.jpg', 4.7, 127.009, 37.509, 'Seoul, South Korea', + 'Beautiful place 9', 'https://example.com/detail9'), + ('place10', 'Place 10', 'https://example.com/place10.jpg', 4.8, 127.010, 37.510, 'Seoul, South Korea', + 'Beautiful place 10', 'https://example.com/detail10'); + +-- MAP 데이터 삽입 +INSERT INTO MAP (user_id, thumbnail_url, title, is_public, description) +VALUES (1, 'https://example.com/map1.jpg', 'Map 1', TRUE, 'Description for Map 1'), + (2, 'https://example.com/map2.jpg', 'Map 2', FALSE, 'Description for Map 2'), + (3, 'https://example.com/map3.jpg', 'Map 3', TRUE, 'Description for Map 3'), + (4, 'https://example.com/map4.jpg', 'Map 4', FALSE, 'Description for Map 4'), + (5, 'https://example.com/map5.jpg', 'Map 5', TRUE, 'Description for Map 5'), + (1, 'https://example.com/map6.jpg', 'Map 6', TRUE, 'Description for Map 6'), + (2, 'https://example.com/map7.jpg', 'Map 7', FALSE, 'Description for Map 7'), + (3, 'https://example.com/map8.jpg', 'Map 8', TRUE, 'Description for Map 8'), + (4, 'https://example.com/map9.jpg', 'Map 9', FALSE, 'Description for Map 9'), + (5, 'https://example.com/map10.jpg', 'Map 10', TRUE, 'Description for Map 10'); + +-- COURSE 데이터 삽입 +INSERT INTO COURSE (user_id, thumbnail_url, title, is_public, description) +VALUES (1, 'https://example.com/course1.jpg', 'Course 1', TRUE, 'Description for Course 1'), + (2, 'https://example.com/course2.jpg', 'Course 2', FALSE, 'Description for Course 2'), + (3, 'https://example.com/course3.jpg', 'Course 3', TRUE, 'Description for Course 3'), + (4, 'https://example.com/course4.jpg', 'Course 4', FALSE, 'Description for Course 4'), + (5, 'https://example.com/course5.jpg', 'Course 5', TRUE, 'Description for Course 5'), + (1, 'https://example.com/course6.jpg', 'Course 6', TRUE, 'Description for Course 6'), + (2, 'https://example.com/course7.jpg', 'Course 7', FALSE, 'Description for Course 7'), + (3, 'https://example.com/course8.jpg', 'Course 8', TRUE, 'Description for Course 8'), + (4, 'https://example.com/course9.jpg', 'Course 9', FALSE, 'Description for Course 9'), + (5, 'https://example.com/course10.jpg', 'Course 10', TRUE, 'Description for Course 10'); + +-- MAP_PLACE 데이터 삽입 +INSERT INTO MAP_PLACE (place_id, map_id, description) +VALUES (1, 1, 'Place 1 in Map 1'), + (2, 2, 'Place 2 in Map 2'), + (3, 3, 'Place 3 in Map 3'), + (4, 4, 'Place 4 in Map 4'), + (5, 5, 'Place 5 in Map 5'), + (6, 6, 'Place 6 in Map 6'), + (7, 7, 'Place 7 in Map 7'), + (8, 8, 'Place 8 in Map 8'), + (9, 9, 'Place 9 in Map 9'), + (10, 10, 'Place 10 in Map 10'), + (1, 2, 'Place 1 in Map 2'), + (2, 3, 'Place 2 in Map 3'), + (3, 4, 'Place 3 in Map 4'); + +-- COURSE_PLACE 데이터 삽입 +INSERT INTO COURSE_PLACE (`order`, place_id, course_id, description) +VALUES (1, 1, 1, 'Place 1 in Course 1'), + (2, 2, 2, 'Place 2 in Course 2'), + (3, 3, 3, 'Place 3 in Course 3'), + (4, 4, 4, 'Place 4 in Course 4'), + (5, 5, 5, 'Place 5 in Course 5'), + (6, 6, 6, 'Place 6 in Course 6'), + (7, 7, 7, 'Place 7 in Course 7'), + (8, 8, 8, 'Place 8 in Course 8'), + (9, 9, 9, 'Place 9 in Course 9'), + (10, 10, 10, 'Place 10 in Course 10'), + (2, 1, 3, 'Place 1 in Course 3'), + (3, 2, 4, 'Place 2 in Course 4'), + (4, 3, 5, 'Place 3 in Course 5'); diff --git a/backend/src/course/course.controller.ts b/backend/src/course/course.controller.ts index 7fc0ef5f..8ce3c433 100644 --- a/backend/src/course/course.controller.ts +++ b/backend/src/course/course.controller.ts @@ -7,10 +7,12 @@ import { Delete, Param, Patch, + Put, } from '@nestjs/common'; import { CreateCourseRequest } from './dto/CreateCourseRequest'; import { UpdateCourseInfoRequest } from './dto/UpdateCourseInfoRequest'; import { CourseService } from './course.service'; +import { SetPlacesOfCourseRequest } from './dto/AddPlaceToCourseRequest'; @Controller('/courses') export class CourseController { @@ -38,9 +40,15 @@ export class CourseController { return await this.courseService.createCourse(userId, createCourseRequest); } - @Delete('/:id') - async deleteCourse(@Param('id') id: number) { - return await this.courseService.deleteCourse(id); + @Put('/:id/places') + async setPlacesOfCourse( + @Param('id') id: number, + @Body() setPlacesOfCourseRequest: SetPlacesOfCourseRequest, + ) { + return await this.courseService.setPlacesOfCourse( + id, + setPlacesOfCourseRequest, + ); } @Patch('/:id/info') @@ -60,4 +68,9 @@ export class CourseController { await this.courseService.updateCourseVisibility(id, isPublic); return { id, isPublic }; } + + @Delete('/:id') + async deleteCourse(@Param('id') id: number) { + return await this.courseService.deleteCourse(id); + } } diff --git a/backend/src/course/course.module.ts b/backend/src/course/course.module.ts index 3d8f00f3..1764ab96 100644 --- a/backend/src/course/course.module.ts +++ b/backend/src/course/course.module.ts @@ -3,9 +3,10 @@ import { CourseService } from './course.service'; import { CourseController } from './course.controller'; import { UserModule } from '../user/user.module'; import { CourseRepository } from './course.repository'; +import { PlaceModule } from '../place/place.module'; @Module({ - imports: [UserModule], + imports: [UserModule, PlaceModule], controllers: [CourseController], providers: [CourseService, CourseRepository], }) diff --git a/backend/src/course/course.service.ts b/backend/src/course/course.service.ts index f9cbdcb1..60ce625e 100644 --- a/backend/src/course/course.service.ts +++ b/backend/src/course/course.service.ts @@ -2,18 +2,24 @@ import { Injectable } from '@nestjs/common'; import { CourseRepository } from './course.repository'; import { User } from '../user/entity/user.entity'; import { CreateCourseRequest } from './dto/CreateCourseRequest'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; import { CourseListResponse } from './dto/CourseListResponse'; -import { CourseDetailResponse } from './dto/CourseDetailResponse'; +import { + CourseDetailResponse, + getPlacesResponseOfCourseWithOrder, +} from './dto/CourseDetailResponse'; import { CourseNotFoundException } from './exception/CourseNotFoundException'; import { UpdateCourseInfoRequest } from './dto/UpdateCourseInfoRequest'; +import { SetPlacesOfCourseRequest } from './dto/AddPlaceToCourseRequest'; +import { PlaceRepository } from '../place/place.repository'; +import { UserRepository } from '../user/user.repository'; +import { InvalidPlaceToCourseException } from './exception/InvalidPlaceToCourseException'; @Injectable() export class CourseService { constructor( private readonly courseRepository: CourseRepository, - @InjectRepository(User) private readonly userRepository: Repository, + private readonly placeRepository: PlaceRepository, + private readonly userRepository: UserRepository, ) { // Todo. 로그인 기능 완성 후 제거 const testUser = new User('test', 'test', 'test', 'test'); @@ -34,7 +40,7 @@ export class CourseService { const publicMaps = maps.filter((map) => map.isPublic); return { - maps: await Promise.all(publicMaps.map(CourseListResponse.from)), + courses: await Promise.all(publicMaps.map(CourseListResponse.from)), totalPages: Math.ceil(totalCount / pageSize), currentPage: page, }; @@ -53,7 +59,7 @@ export class CourseService { ); return { - maps: await Promise.all(ownMaps.map(CourseListResponse.from)), + courses: await Promise.all(ownMaps.map(CourseListResponse.from)), totalPages: Math.ceil(totalCount / pageSize), currentPage: page, }; @@ -97,4 +103,38 @@ export class CourseService { if (!(await this.courseRepository.existById(id))) throw new CourseNotFoundException(id); } + + async setPlacesOfCourse( + id: number, + setPlacesOfCourseRequest: SetPlacesOfCourseRequest, + ) { + const course = await this.courseRepository.findById(id); + if (!course) throw new CourseNotFoundException(id); + + await this.checkPlacesExist( + setPlacesOfCourseRequest.places.map((p) => p.placeId), + ); + + course.setPlaces(setPlacesOfCourseRequest.places); + await this.courseRepository.save(course); // Todo. Q.바로 장소 조회하면 장소 정보가 없음.. (장소 참조만 객체에 저장했기 때문) + const reloadedCourse = await this.courseRepository.findById(course.id); + + return { + places: await getPlacesResponseOfCourseWithOrder(reloadedCourse), + }; + } + + private async checkPlacesExist(placeIds: number[]) { + const notExistsPlaceIds = await Promise.all( + placeIds.map(async (placeId) => { + const exists = await this.placeRepository.existById(placeId); + return exists ? null : placeId; + }), + ); + + const invalidIds = notExistsPlaceIds.filter((placeId) => placeId !== null); + if (invalidIds.length > 0) { + throw new InvalidPlaceToCourseException(invalidIds); + } + } } diff --git a/backend/src/course/dto/AddPlaceToCourseRequest.ts b/backend/src/course/dto/AddPlaceToCourseRequest.ts new file mode 100644 index 00000000..66f54d5f --- /dev/null +++ b/backend/src/course/dto/AddPlaceToCourseRequest.ts @@ -0,0 +1,27 @@ +import { + IsNumber, + IsNotEmpty, + IsString, + IsArray, + ValidateNested, + Validate, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsNotConsecutiveDuplicatePlace } from '../pipes/IsNotConsecutiveDuplicatePlace'; + +export class SetPlacesOfCourseRequestItem { + @IsNumber() + @IsNotEmpty() + placeId: number; + + @IsString() + comment?: string; +} + +export class SetPlacesOfCourseRequest { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SetPlacesOfCourseRequestItem) + @Validate(IsNotConsecutiveDuplicatePlace) + places: SetPlacesOfCourseRequestItem[]; +} diff --git a/backend/src/course/dto/CourseDetailResponse.ts b/backend/src/course/dto/CourseDetailResponse.ts index d7aa1213..d06b079a 100644 --- a/backend/src/course/dto/CourseDetailResponse.ts +++ b/backend/src/course/dto/CourseDetailResponse.ts @@ -18,13 +18,7 @@ export class CourseDetailResponse { ) {} static async from(course: Course) { - const places = (await course.getPlacesWithComment()).map((place, index) => { - return { - ...PlaceResponse.from(place.place), - comment: place.comment, - order: index + 1, - }; - }); + const places = await getPlacesResponseOfCourseWithOrder(course); return new CourseDetailResponse( course.id, @@ -40,3 +34,13 @@ export class CourseDetailResponse { ); } } + +export async function getPlacesResponseOfCourseWithOrder(course: Course) { + return (await course.getPlacesWithComment()).map((place, index) => { + return { + ...PlaceResponse.from(place.place), + comment: place.comment, + order: index + 1, + }; + }); +} diff --git a/backend/src/course/entity/course-place.entity.ts b/backend/src/course/entity/course-place.entity.ts index 66a97a7f..1977160b 100644 --- a/backend/src/course/entity/course-place.entity.ts +++ b/backend/src/course/entity/course-place.entity.ts @@ -12,10 +12,27 @@ export class CoursePlace extends BaseEntity { @JoinColumn({ name: 'place_id' }) place: Promise; - @ManyToOne(() => Course, { onDelete: 'CASCADE' }) + @ManyToOne(() => Course, { + onDelete: 'CASCADE', + orphanedRowAction: 'delete', + }) @JoinColumn({ name: 'course_id' }) - course: Promise; + course: Course; @Column('text', { nullable: true }) description?: string; + + static of( + order: number, + placeId: number, + course: Course, + description?: string, + ) { + const place = new CoursePlace(); + place.course = course; + place.order = order; + place.place = Promise.resolve({ id: placeId } as Place); + place.description = description; + return place; + } } diff --git a/backend/src/course/entity/course.entity.ts b/backend/src/course/entity/course.entity.ts index 7f3663ad..669c840c 100644 --- a/backend/src/course/entity/course.entity.ts +++ b/backend/src/course/entity/course.entity.ts @@ -2,6 +2,7 @@ import { Entity, Column, ManyToOne, JoinColumn, OneToMany } from 'typeorm'; import { BaseEntity } from '../../common/BaseEntity'; import { User } from '../../user/entity/user.entity'; import { CoursePlace } from './course-place.entity'; +import { SetPlacesOfCourseRequestItem } from '../dto/AddPlaceToCourseRequest'; @Entity() export class Course extends BaseEntity { @@ -23,8 +24,9 @@ export class Course extends BaseEntity { @OneToMany(() => CoursePlace, (coursePlace) => coursePlace.course, { eager: true, + cascade: true, }) - private coursePlaces: CoursePlace[]; + coursePlaces: CoursePlace[]; constructor( user: User, @@ -45,9 +47,14 @@ export class Course extends BaseEntity { return this.coursePlaces.length; } + setPlaces(coursePlaces: SetPlacesOfCourseRequestItem[]) { + this.coursePlaces = coursePlaces.map((item, index) => { + return CoursePlace.of(index + 1, item.placeId, this, item.comment); + }); + } + async getPlacesWithComment() { const coursePlaces = this.coursePlaces.sort((a, b) => a.order - b.order); - return await Promise.all( coursePlaces.map(async (coursePlace) => ({ place: await coursePlace.place, diff --git a/backend/src/course/exception/ConsecutivePlaceException.ts b/backend/src/course/exception/ConsecutivePlaceException.ts new file mode 100644 index 00000000..5e8e8101 --- /dev/null +++ b/backend/src/course/exception/ConsecutivePlaceException.ts @@ -0,0 +1,12 @@ +import { BaseException } from '../../common/exception/BaseException'; +import { HttpStatus } from '@nestjs/common'; + +export class ConsecutivePlaceException extends BaseException { + constructor() { + super({ + code: 904, + message: '동일한 장소는 연속된 순서로 추가할 수 없습니다.', + status: HttpStatus.BAD_REQUEST, + }); + } +} diff --git a/backend/src/course/exception/CourseNotFoundException.ts b/backend/src/course/exception/CourseNotFoundException.ts index 0992b62f..b71684ad 100644 --- a/backend/src/course/exception/CourseNotFoundException.ts +++ b/backend/src/course/exception/CourseNotFoundException.ts @@ -4,7 +4,7 @@ import { HttpStatus } from '@nestjs/common'; export class CourseNotFoundException extends BaseException { constructor(id: number) { super({ - code: 802, + code: 902, message: `id:${id} 코스가 존재하지 않거나 삭제되었습니다.`, status: HttpStatus.NOT_FOUND, }); diff --git a/backend/src/course/exception/InvalidPlaceToCourseException.ts b/backend/src/course/exception/InvalidPlaceToCourseException.ts new file mode 100644 index 00000000..4ae815b5 --- /dev/null +++ b/backend/src/course/exception/InvalidPlaceToCourseException.ts @@ -0,0 +1,12 @@ +import { BaseException } from '../../common/exception/BaseException'; +import { HttpStatus } from '@nestjs/common'; + +export class InvalidPlaceToCourseException extends BaseException { + constructor(invalidPlaceIds: number[]) { + super({ + code: 903, + message: `존재하지 않는 장소를 코스에 추가할 수 없습니다. : ${invalidPlaceIds.join(', ')}`, + status: HttpStatus.BAD_REQUEST, + }); + } +} diff --git a/backend/src/course/pipes/IsNotConsecutiveDuplicatePlace.ts b/backend/src/course/pipes/IsNotConsecutiveDuplicatePlace.ts new file mode 100644 index 00000000..25af243e --- /dev/null +++ b/backend/src/course/pipes/IsNotConsecutiveDuplicatePlace.ts @@ -0,0 +1,24 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { SetPlacesOfCourseRequestItem } from '../dto/AddPlaceToCourseRequest'; +import { ConsecutivePlaceException } from '../exception/ConsecutivePlaceException'; + +@ValidatorConstraint({ name: 'isNotConsecutiveDuplicatePlace', async: false }) +export class IsNotConsecutiveDuplicatePlace + implements ValidatorConstraintInterface +{ + validate(places: SetPlacesOfCourseRequestItem[]) { + for (let i = 1; i < places.length; i++) { + if (places[i].placeId === places[i - 1].placeId) { + throw new ConsecutivePlaceException(); + } + } + return true; + } + + defaultMessage() { + return '동일한 장소는 연속된 순서로 추가할 수 없습니다.'; + } +} diff --git a/backend/src/map/dto/AddPlaceToMapRequest.ts b/backend/src/map/dto/AddPlaceToMapRequest.ts new file mode 100644 index 00000000..0d220b05 --- /dev/null +++ b/backend/src/map/dto/AddPlaceToMapRequest.ts @@ -0,0 +1,9 @@ +import { IsNumber, IsString } from 'class-validator'; + +export class AddPlaceToMapRequest { + @IsNumber() + placeId: number; + + @IsString() + comment?: string; +} diff --git a/backend/src/map/entity/map-place.entity.ts b/backend/src/map/entity/map-place.entity.ts index 82e9e3e3..6b9c301f 100644 --- a/backend/src/map/entity/map-place.entity.ts +++ b/backend/src/map/entity/map-place.entity.ts @@ -5,14 +5,29 @@ import { Map } from './map.entity'; @Entity() export class MapPlace extends BaseEntity { + @Column() + placeId: number; + @ManyToOne(() => Place, { onDelete: 'CASCADE', lazy: true }) @JoinColumn({ name: 'place_id' }) place: Promise; - @ManyToOne(() => Map, (map) => map.mapPlaces, { onDelete: 'CASCADE' }) + @ManyToOne(() => Map, (map) => map.mapPlaces, { + onDelete: 'CASCADE', + orphanedRowAction: 'delete', + }) @JoinColumn({ name: 'map_id' }) map: Map; @Column('text', { nullable: true }) description?: string; + + static of(placeId: number, map: Map, description?: string) { + const place = new MapPlace(); + place.map = map; + place.placeId = placeId; + place.place = Promise.resolve({ id: placeId } as Place); + place.description = description; + return place; + } } diff --git a/backend/src/map/entity/map.entity.ts b/backend/src/map/entity/map.entity.ts index 89c121c6..bc58ccda 100644 --- a/backend/src/map/entity/map.entity.ts +++ b/backend/src/map/entity/map.entity.ts @@ -21,7 +21,10 @@ export class Map extends BaseEntity { @Column('text', { nullable: true }) description?: string; - @OneToMany(() => MapPlace, (mapPlace) => mapPlace.map, { eager: true }) + @OneToMany(() => MapPlace, (mapPlace) => mapPlace.map, { + eager: true, + cascade: true, + }) mapPlaces: MapPlace[]; constructor( @@ -43,6 +46,18 @@ export class Map extends BaseEntity { return this.mapPlaces.length; } + addPlace(placeId: number, description: string) { + this.mapPlaces.push(MapPlace.of(placeId, this, description)); + } + + async deletePlace(placeId: number) { + this.mapPlaces = this.mapPlaces.filter((p) => p.placeId !== placeId); + } + + async hasPlace(placeId: number) { + return this.mapPlaces.some((p) => p.placeId === placeId); + } + async getPlacesWithComment() { return await Promise.all( this.mapPlaces.map(async (mapPlace) => ({ diff --git a/backend/src/map/exception/DuplicatePlaceToMapException.ts b/backend/src/map/exception/DuplicatePlaceToMapException.ts new file mode 100644 index 00000000..fb66e210 --- /dev/null +++ b/backend/src/map/exception/DuplicatePlaceToMapException.ts @@ -0,0 +1,12 @@ +import { BaseException } from '../../common/exception/BaseException'; +import { HttpStatus } from '@nestjs/common'; + +export class DuplicatePlaceToMapException extends BaseException { + constructor(id: number) { + super({ + code: 805, + message: '이미 지도에 존재하는 장소입니다. : ' + id, + status: HttpStatus.CONFLICT, + }); + } +} diff --git a/backend/src/map/exception/InvalidPlaceToMapException.ts b/backend/src/map/exception/InvalidPlaceToMapException.ts new file mode 100644 index 00000000..4544969c --- /dev/null +++ b/backend/src/map/exception/InvalidPlaceToMapException.ts @@ -0,0 +1,12 @@ +import { BaseException } from '../../common/exception/BaseException'; +import { HttpStatus } from '@nestjs/common'; + +export class InvalidPlaceToMapException extends BaseException { + constructor(invalidPlaceId: number) { + super({ + code: 803, + message: `존재하지 않는 장소를 지도에 추가할 수 없습니다. : ${invalidPlaceId}`, + status: HttpStatus.BAD_REQUEST, + }); + } +} diff --git a/backend/src/map/map.controller.ts b/backend/src/map/map.controller.ts index 6fa6c38d..023fd80a 100644 --- a/backend/src/map/map.controller.ts +++ b/backend/src/map/map.controller.ts @@ -11,36 +11,49 @@ import { import { MapService } from './map.service'; import { CreateMapRequest } from './dto/CreateMapRequest'; import { UpdateMapInfoRequest } from './dto/UpdateMapInfoRequest'; +import { AddPlaceToMapRequest } from './dto/AddPlaceToMapRequest'; @Controller('/maps') export class MapController { - constructor(private readonly appService: MapService) {} + constructor(private readonly mapService: MapService) {} @Get() async getMapList(@Query('query') query?: string) { - return await this.appService.searchMap(query); + return await this.mapService.searchMap(query); } @Get('/my') async getMyMapList() { const userId = 1; // Todo. 로그인 기능 완성 후 수정 - return await this.appService.getOwnMaps(userId); + return await this.mapService.getOwnMaps(userId); } @Get('/:id') async getMapDetail(@Param('id') id: number) { - return await this.appService.getMapById(id); + return await this.mapService.getMapById(id); } @Post() async createMap(@Body() createMapRequest: CreateMapRequest) { const userId = 1; // Todo. 로그인 기능 완성 후 수정 - return await this.appService.createMap(userId, createMapRequest); + return await this.mapService.createMap(userId, createMapRequest); } - @Delete('/:id') - async deleteMap(@Param('id') id: number) { - return await this.appService.deleteMap(id); + @Post('/:id/places') + async addPlaceToMap( + @Param('id') id: number, + @Body() addPlaceToMapRequest: AddPlaceToMapRequest, + ) { + const { placeId, comment } = addPlaceToMapRequest; + return await this.mapService.addPlace(id, placeId, comment); + } + + @Delete('/:id/places/:placeId') + async deletePlaceFromMap( + @Param('id') id: number, + @Param('placeId') placeId: number, + ) { + return await this.mapService.deletePlace(id, placeId); } @Patch('/:id/info') @@ -48,7 +61,7 @@ export class MapController { @Param('id') id: number, @Body() updateMapInfoRequest: UpdateMapInfoRequest, ) { - await this.appService.updateMapInfo(id, updateMapInfoRequest); + await this.mapService.updateMapInfo(id, updateMapInfoRequest); return { id, ...updateMapInfoRequest }; } @@ -57,7 +70,12 @@ export class MapController { @Param('id') id: number, @Body('isPublic') isPublic: boolean, ) { - await this.appService.updateMapVisibility(id, isPublic); + await this.mapService.updateMapVisibility(id, isPublic); return { id, isPublic }; } + + @Delete('/:id') + async deleteMap(@Param('id') id: number) { + return await this.mapService.deleteMap(id); + } } diff --git a/backend/src/map/map.module.ts b/backend/src/map/map.module.ts index 8bcb6ddf..912cf6e6 100644 --- a/backend/src/map/map.module.ts +++ b/backend/src/map/map.module.ts @@ -3,9 +3,10 @@ import { MapService } from './map.service'; import { MapController } from './map.controller'; import { UserModule } from '../user/user.module'; import { MapRepository } from './map.repository'; +import { PlaceModule } from '../place/place.module'; @Module({ - imports: [UserModule], + imports: [UserModule, PlaceModule], controllers: [MapController], providers: [MapService, MapRepository], }) diff --git a/backend/src/map/map.service.ts b/backend/src/map/map.service.ts index 9a9fd538..6f93ed70 100644 --- a/backend/src/map/map.service.ts +++ b/backend/src/map/map.service.ts @@ -7,12 +7,17 @@ import { UserRepository } from '../user/user.repository'; import { UpdateMapInfoRequest } from './dto/UpdateMapInfoRequest'; import { CreateMapRequest } from './dto/CreateMapRequest'; import { MapNotFoundException } from './exception/MapNotFoundException'; +import { DuplicatePlaceToMapException } from './exception/DuplicatePlaceToMapException'; +import { PlaceRepository } from '../place/place.repository'; +import { InvalidPlaceToMapException } from './exception/InvalidPlaceToMapException'; +import { Map } from './entity/map.entity'; @Injectable() export class MapService { constructor( private readonly mapRepository: MapRepository, private readonly userRepository: UserRepository, + private readonly placeRepository: PlaceRepository, ) { // Todo. 로그인 기능 완성 후 제거 const testUser = new User('test', 'test', 'test', 'test'); @@ -21,6 +26,7 @@ export class MapService { } // Todo. 작성자명 등 ... 검색 조건 추가 + // Todo. fix : public 으로 조회해서 페이지마다 수 일정하게. (현재는 한 페이지에 10개 미만인 경우 존재) async searchMap(query?: string, page: number = 1, pageSize: number = 10) { const maps = query ? await this.mapRepository.searchByTitleQuery(query, page, pageSize) @@ -60,7 +66,7 @@ export class MapService { async getMapById(id: number) { const map = await this.mapRepository.findById(id); - if (map) throw new MapNotFoundException(id); + if (!map) throw new MapNotFoundException(id); return await MapDetailResponse.from(map); } @@ -96,4 +102,38 @@ export class MapService { if (!(await this.mapRepository.existById(id))) throw new MapNotFoundException(id); } + + async addPlace(id: number, placeId: number, comment?: string) { + const map = await this.mapRepository.findById(id); + if (!map) throw new MapNotFoundException(id); + await this.checkPlaceCanAddToMap(placeId, map); + + map.addPlace(placeId, comment); + await this.mapRepository.save(map); + + return { + savedPlaceId: placeId, + comment: comment, + }; + } + + private async checkPlaceCanAddToMap(placeId: number, map: Map) { + if (!(await this.placeRepository.existById(placeId))) { + throw new InvalidPlaceToMapException(placeId); + } + + if (await map.hasPlace(placeId)) { + throw new DuplicatePlaceToMapException(placeId); + } + } + + async deletePlace(id: number, placeId: number) { + const map = await this.mapRepository.findById(id); + if (!map) throw new MapNotFoundException(id); + + map.deletePlace(placeId); + await this.mapRepository.save(map); + + return { deletedId: placeId }; + } } diff --git a/backend/src/place/place.module.ts b/backend/src/place/place.module.ts index fe58daca..16de9482 100644 --- a/backend/src/place/place.module.ts +++ b/backend/src/place/place.module.ts @@ -6,5 +6,6 @@ import { PlaceRepository } from './place.repository'; @Module({ controllers: [PlaceController], providers: [PlaceService, PlaceRepository], + exports: [PlaceRepository], }) export class PlaceModule {}