diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..f2a327cd --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,54 @@ +name: Build & Deploy to EC2 via CodeDeploy + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + # 1. 코드 체크아웃 + - name: Checkout + uses: actions/checkout@v4.2.2 + + # 2. Node.js 설치 + - uses: actions/setup-node@v3 + with: + node-version: 22 + cache: "npm" + + # 3. 의존성 설치 + - run: npm install + + # 4. Build + - run: npx prisma generate + - run: npm run build + + # 5. 환경 변수 생성 (.env) + - name: Create .env file from secrets + run: | + echo "${{ secrets.PRODUCTION_ENV }}" > .env + + # 6. AWS CLI 설정 + - uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + + # 7. 빌드 결과물 zip + - run: zip -r app.zip dist package.json package-lock.json prisma scripts appspec.yml .env + + # 8. S3 업로드 + - run: aws s3 cp app.zip s3://${{ secrets.S3_BUCKET_NAME }}/${{ secrets.S3_BUILD_DIRECTORY_PATH }}/app.zip + + # 9. CodeDeploy 배포 생성 + - run: | + aws deploy create-deployment \ + --application-name ${{ secrets.DEPLOY_APPLICATION_NAME }} \ + --deployment-group-name ${{ secrets.DEPLOY_GROUP_NAME }} \ + --s3-location bucket=${{ secrets.S3_BUCKET_NAME }},key=${{ secrets.S3_BUILD_DIRECTORY_PATH }}/app.zip,bundleType=zip \ + --deployment-config-name CodeDeployDefault.OneAtATime diff --git a/appspec.yml b/appspec.yml new file mode 100644 index 00000000..6bd1fc0a --- /dev/null +++ b/appspec.yml @@ -0,0 +1,12 @@ +version: 0.0 +os: linux + +files: + - source: / + destination: /home/ec2-user/app/deploy-temp + +hooks: + AfterInstall: + - location: scripts/deploy.sh + timeout: 600 + runas: ec2-user diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6f1eb1f3..ac302bbb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,11 +8,11 @@ generator client { provider = "prisma-client-js" } -generator markdown { - provider = "prisma-markdown" - output = "./ERD.md" - title = "Metamorn-ERD" -} +// generator markdown { +// provider = "prisma-markdown" +// output = "./ERD.md" +// title = "Metamorn-ERD" +// } generator kysely { provider = "prisma-kysely" diff --git a/scripts/deploy-test.sh b/scripts/deploy-test.sh new file mode 100644 index 00000000..4deae011 --- /dev/null +++ b/scripts/deploy-test.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# 테스트 시작 메시지 +echo "=== Deploy Test: AfterInstall Hook Start ===" + +# 현재 경로 출력 +echo "Current directory: $(pwd)" + +# 파일/폴더 목록 출력 +echo "Directory contents:" +ls -al + +# 환경 변수 출력 (AWS 관련 변수 예시) +echo "USER: $USER" +echo "HOME: $HOME" + +# 테스트 완료 메시지 +echo "=== Deploy Test: AfterInstall Hook End ===" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 00000000..9b31092c --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,61 @@ +#!/bin/bash +set -e + +APP_BASE="/home/ec2-user/app" +CURRENT_DIR="$APP_BASE/current" +TEMP_DIR="$APP_BASE/deploy-temp" +NEW_DIR="$APP_BASE/new_$(date +%s)" +BACKUP_DIR="$APP_BASE/backup" + +echo "=== Deploy Hook Start ===" +echo "Temporary deploy dir: $TEMP_DIR" + +# 1. CodeDeploy agent가 root로 실행하므로 소유권 변경하며 복사 +rsync -a --chown=ec2-user:ec2-user "$TEMP_DIR/" "$NEW_DIR/" + +# 2. 의존성 설치 +cd "$NEW_DIR" +npm install --production + +# 3. prisma client 생성 +npx prisma generate + +# 4. 기존 PM2 프로세스 중지 및 삭제 +pm2 describe lia-server > /dev/null 2>&1 && { + echo "Stopping existing process..." + pm2 stop lia-server + pm2 delete lia-server +} || echo "No existing process found" + +# 5. 기존 버전 백업 +if [ -d "$CURRENT_DIR" ]; then + rm -rf "$BACKUP_DIR" + mv "$CURRENT_DIR" "$BACKUP_DIR" +fi + +# 6. 새 버전을 current로 이동 (PM2 시작 전에), 시작 후 옮기면 경로 꼬임 +mv "$NEW_DIR" "$CURRENT_DIR" + +# 7. current 디렉토리에서 PM2 시작 +cd "$CURRENT_DIR" +pm2 start dist/src/main.js --name lia-server || { + echo "=== Deploy Failed, Rolling Back ===" + + # 실패한 current 제거 + cd "$APP_BASE" + rm -rf "$CURRENT_DIR" + + # 백업 버전 복구 + if [ -d "$BACKUP_DIR" ]; then + mv "$BACKUP_DIR" "$CURRENT_DIR" + cd "$CURRENT_DIR" + pm2 start dist/src/main.js --name lia-server + echo "Rollback completed" + fi + exit 1 +} + +# 8. PM2 설정 저장 +pm2 save + +echo "=== Deploy Successful ===" \ No newline at end of file diff --git a/src/domain/components/islands/private-island-writer.ts b/src/domain/components/islands/private-island-writer.ts index 3125c43c..fa66b10c 100644 --- a/src/domain/components/islands/private-island-writer.ts +++ b/src/domain/components/islands/private-island-writer.ts @@ -26,4 +26,8 @@ export class PrivateIslandWriter { return island; } + + async delete(id: string, now = new Date()) { + await this.privateIslandRepository.delete(id, now); + } } diff --git a/src/domain/interface/private-island.repository.ts b/src/domain/interface/private-island.repository.ts index 1f467662..f76c59d3 100644 --- a/src/domain/interface/private-island.repository.ts +++ b/src/domain/interface/private-island.repository.ts @@ -17,6 +17,7 @@ export interface PrivateIslandRepository { ): Promise<{ id: string; password: string | null } | null>; findPasswordById(id: string): Promise; findOneById(id: string): Promise; + delete(id: string, now?: Date): Promise; } export const PrivateIslandRepository = Symbol('PrivateIslandRepository'); diff --git a/src/domain/services/islands/private-island.service.ts b/src/domain/services/islands/private-island.service.ts index 286590d1..24d9879d 100644 --- a/src/domain/services/islands/private-island.service.ts +++ b/src/domain/services/islands/private-island.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { CreatePrivateIslandInput } from 'src/domain/types/island.types'; import { generateRandomBase62 } from 'src/utils/random'; import { PrivateIslandWriter } from 'src/domain/components/islands/private-island-writer'; @@ -7,6 +7,9 @@ import { PRIVATE_ISLAND_MAX_MEMBERS } from 'src/common/constants'; import { MapReader } from 'src/domain/components/map/map-reader'; import { UserReader } from 'src/domain/components/users/user-reader'; import { PrivateIslandPasswordChecker } from 'src/domain/components/islands/private-storage/private-island-password-checker'; +import { DomainException } from 'src/domain/exceptions/exceptions'; +import { DomainExceptionType } from 'src/domain/exceptions/enum/domain-exception-type'; +import { FORBIDDEN_MESSAGE } from 'src/domain/exceptions/message'; @Injectable() export class PrivateIslandService { @@ -56,4 +59,21 @@ export class PrivateIslandService { password, ); } + + async remove(id: string, userId: string, now = new Date()): Promise { + await this.checkOwnership(id, userId); + await this.privateIslandWriter.delete(id, now); + } + + async checkOwnership(islandId: string, userId: string): Promise { + const { ownerId } = await this.privateIslandReader.readOne(islandId); + + if (ownerId !== userId) { + throw new DomainException( + DomainExceptionType.FORBIDDEN, + HttpStatus.FORBIDDEN, + FORBIDDEN_MESSAGE, + ); + } + } } diff --git a/src/infrastructure/repositories/private-island-prisma.repository.ts b/src/infrastructure/repositories/private-island-prisma.repository.ts index 617c21f9..229e3997 100644 --- a/src/infrastructure/repositories/private-island-prisma.repository.ts +++ b/src/infrastructure/repositories/private-island-prisma.repository.ts @@ -53,6 +53,7 @@ export class PrivateIslandPrismaRepository implements PrivateIslandRepository { }, where: { ownerId, + deletedAt: null, }, skip: (page - 1) * limit, take: limit, @@ -72,7 +73,7 @@ export class PrivateIslandPrismaRepository implements PrivateIslandRepository { async countByOwner(ownerId: string): Promise { return await this.txHost.tx.privateIsland.count({ - where: { ownerId }, + where: { ownerId, deletedAt: null }, }); } @@ -81,7 +82,7 @@ export class PrivateIslandPrismaRepository implements PrivateIslandRepository { ): Promise<{ id: string; password: string | null } | null> { return await this.txHost.tx.privateIsland.findFirst({ select: { id: true, password: true }, - where: { urlPath }, + where: { urlPath, deletedAt: null }, }); } @@ -89,7 +90,7 @@ export class PrivateIslandPrismaRepository implements PrivateIslandRepository { id: string, ): Promise { return await this.txHost.tx.privateIsland.findUnique({ - where: { id }, + where: { id, deletedAt: null }, select: { id: true, password: true, @@ -99,7 +100,7 @@ export class PrivateIslandPrismaRepository implements PrivateIslandRepository { async findOneById(id: string): Promise { const result = await this.txHost.tx.privateIsland.findUnique({ - where: { id }, + where: { id, deletedAt: null }, select: { id: true, ownerId: true, @@ -130,4 +131,15 @@ export class PrivateIslandPrismaRepository implements PrivateIslandRepository { mapKey: map.key, }; } + + async delete(id: string, now = new Date()): Promise { + await this.txHost.tx.privateIsland.update({ + where: { + id, + }, + data: { + deletedAt: now, + }, + }); + } } diff --git a/src/presentation/controller/islands/private-island.controller.ts b/src/presentation/controller/islands/private-island.controller.ts index 32fe7b93..5ad86ddb 100644 --- a/src/presentation/controller/islands/private-island.controller.ts +++ b/src/presentation/controller/islands/private-island.controller.ts @@ -1,4 +1,13 @@ -import { Body, Get, HttpCode, Param, Post, Query } from '@nestjs/common'; +import { + Body, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { CurrentUser } from 'src/common/decorator/current-user.decorator'; import { LivislandController } from 'src/common/decorator/livisland-controller.decorator'; @@ -118,4 +127,25 @@ export class PrivateIslandController { ): Promise { return await this.privateIslandService.checkPassword(id, dto.password); } + + @ApiOperation({ + summary: '비밀섬 삭제', + description: '비밀섬 삭제', + }) + @ApiResponse({ + status: 204, + description: '삭제 완료', + }) + @ApiResponse({ + status: 403, + description: '삭제 권한 없음', + }) + @HttpCode(204) + @Delete(':id') + async remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() userId: string, + ) { + await this.privateIslandService.remove(id, userId); + } } diff --git a/test/e2e/private-island.e2e-spec.ts b/test/e2e/private-island.e2e-spec.ts index 86212dcb..e50f1e4e 100644 --- a/test/e2e/private-island.e2e-spec.ts +++ b/test/e2e/private-island.e2e-spec.ts @@ -140,6 +140,80 @@ describe('PrivateIslandController (e2e)', () => { expect(island.id).toEqual(body.islands[i].id); }); }); + + it('삭제된 섬은 조회되지 않는다', async () => { + // Given: 첫 번째 섬을 삭제 처리 + const targetIsland = islands[0]; + await db.privateIsland.update({ + where: { id: targetIsland.id }, + data: { deletedAt: new Date() }, + }); + + // When: 내 섬 목록을 조회 + const dto: GetMyPrivateIslandRequest = { + limit: 20, + order: 'desc', + page: 1, + sortBy: 'createdAt', + }; + const response = await request(app.getHttpServer()) + .get('/private-islands/my') + .query(dto) + .set('Authorization', authToken); + + const { + status, + body, + }: ResponseResult = response; + + // Then: 삭제된 섬은 결과에 포함되지 않아야 함 + expect(status).toBe(200); + expect(body.islands.length).toBe(9); // 10개 중 1개가 삭제되어 9개 + + // 삭제된 섬의 ID가 결과에 포함되지 않았는지 확인 + const returnedIslandIds = body.islands.map((island) => island.id); + expect(returnedIslandIds).not.toContain(targetIsland.id); + }); + + it('여러 섬이 삭제된 경우 모두 조회에서 제외된다', async () => { + // Given: 처음 3개 섬을 삭제 처리 + const deletedIslands = islands.slice(0, 3); + await db.privateIsland.updateMany({ + where: { + id: { + in: deletedIslands.map((island) => island.id), + }, + }, + data: { deletedAt: new Date() }, + }); + + // When: 내 섬 목록을 조회 + const dto: GetMyPrivateIslandRequest = { + limit: 20, + order: 'desc', + page: 1, + sortBy: 'createdAt', + }; + const response = await request(app.getHttpServer()) + .get('/private-islands/my') + .query(dto) + .set('Authorization', authToken); + + const { + status, + body, + }: ResponseResult = response; + + // Then: 삭제된 섬들은 모두 결과에 포함되지 않아야 함 + expect(status).toBe(200); + expect(body.islands.length).toBe(7); // 10개 중 3개가 삭제되어 7개 + + // 삭제된 섬들의 ID가 결과에 포함되지 않았는지 확인 + const returnedIslandIds = body.islands.map((island) => island.id); + deletedIslands.forEach((deletedIsland) => { + expect(returnedIslandIds).not.toContain(deletedIsland.id); + }); + }); }); describe('(POST) /private-islands/:id/password', () => { @@ -237,4 +311,101 @@ describe('PrivateIslandController (e2e)', () => { expect(response.status).toBe(400); }); }); + + describe('DELETE /private-islands/:id - 비밀섬 삭제', () => { + let authToken: string; + let userId: string; + let otherAuthToken: string; + let map: { id: string; key: string }; + let privateIsland: PrivateIslandEntity; + + beforeEach(async () => { + const loginResult = await login(app); + authToken = loginResult.accessToken; + userId = loginResult.userId; + + const otherLoginResult = await login(app); + otherAuthToken = otherLoginResult.accessToken; + + map = await db.map.create({ + data: { + id: v4(), + key: 'test-map', + createdAt: new Date(), + description: '테스트 맵', + image: 'https://example.com/image.jpg', + name: '테스트 맵', + }, + }); + + const privateIslandData = generatePrivateIsland(map.id, userId, { + name: '삭제할 섬', + isPublic: true, + description: '삭제 테스트용 섬', + }); + + privateIsland = await db.privateIsland.create({ + data: privateIslandData, + }); + }); + + it('정상 동작', async () => { + const response = await request(app.getHttpServer()) + .delete(`/private-islands/${privateIsland.id}`) + .set('Authorization', authToken); + + expect(response.status).toBe(204); + + // soft delete되어 deletedAt이 설정되었는지 확인 + const deletedIsland = await db.privateIsland.findUnique({ + where: { id: privateIsland.id }, + }); + expect(deletedIsland).not.toBeNull(); + expect(deletedIsland?.deletedAt).not.toBeNull(); + }); + + it('섬의 주인이 아닌 회원이 삭제를 시도하는 경우 예외가 발생한다', async () => { + const response = await request(app.getHttpServer()) + .delete(`/private-islands/${privateIsland.id}`) + .set('Authorization', otherAuthToken); + + expect(response.status).toBe(403); + + // 섬이 삭제되지 않았는지 확인 (deletedAt이 null인지 확인) + const existingIsland = await db.privateIsland.findUnique({ + where: { id: privateIsland.id }, + }); + expect(existingIsland).not.toBeNull(); + expect(existingIsland?.id).toBe(privateIsland.id); + expect(existingIsland?.deletedAt).toBeNull(); + }); + + it('존재하지 않는 섬을 삭제 시도 시 예외가 발생한다', async () => { + const nonExistentIslandId = v4(); + + const response = await request(app.getHttpServer()) + .delete(`/private-islands/${nonExistentIslandId}`) + .set('Authorization', authToken); + + expect(response.status).toBe(404); + }); + + it('인증 없이 삭제 요청 시 예외가 발생한다', async () => { + const response = await request(app.getHttpServer()).delete( + `/private-islands/${privateIsland.id}`, + ); + + expect(response.status).toBe(401); + }); + + it('잘못된 UUID 형식의 ID로 삭제 시도 시 예외가 발생한다', async () => { + const invalidId = 'invalid-uuid'; + + const response = await request(app.getHttpServer()) + .delete(`/private-islands/${invalidId}`) + .set('Authorization', authToken); + + expect(response.status).toBe(400); + }); + }); });