diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 5be3723..4cc918d 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -43,8 +43,8 @@ jobs: - name: Check removed Spotify metadata run: pnpm run check:no-spotify-metadata - - name: Run API tests with mock DB - run: pnpm run test:api + - name: Run API tests with coverage + run: pnpm run test:coverage env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/soundlog_ci?schema=public JWT_SECRET: soundlog-ci-test-secret diff --git a/README.md b/README.md index 546a1a9..eb3da99 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,11 @@ EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=http://localhost:4000 npm run web - Swagger UI: `http://localhost:4000/docs` - OpenAPI YAML: `http://localhost:4000/openapi.yaml` +- 운영 API 프록시: `https://soundlog.shop/api/soundlog` + +Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능한 개발용 API를 사용할 수 있습니다. + +- `POST /v1/dev/db-test-records`: `DbTestRecord` row 생성 또는 mock 응답 반환 ## Production hardening @@ -118,18 +123,57 @@ pnpm db:seed # 로컬 seed 데이터 적재 ## API Groups -- System -- Auth -- Me -- Tour -- Home -- Playlists -- Library -- MomentLogs -- RecommendationEvents -- Recaps -- TravelSessions +- System / Dev + - `GET /v1/health` + - `POST /v1/dev/db-test-records` +- Auth / Me + - `POST /v1/auth/register` + - `POST /v1/auth/login` + - `POST /v1/auth/refresh` + - `POST /v1/auth/logout` + - `GET /v1/me` + - `PATCH /v1/me/profile` + - `POST /v1/me/migrate-local-data` +- Tour / Home / Playlists + - `GET /v1/tour/nearby-places` + - `GET /v1/home/featured-playlists` + - `GET /v1/home/mood-recommendations` + - `GET /v1/home/recent-music-logs` + - `POST /v1/playlists/contextual` + - `GET /v1/playlists/:playlistId` +- Moment Logs / Library / Recaps + - `GET /v1/moment-logs` + - `POST /v1/moment-logs` + - `GET /v1/library/tracks` + - `PUT /v1/library/tracks/:trackId` + - `POST /v1/recommendation-events` + - `GET /v1/recaps` + - `POST /v1/recaps` + - `GET /v1/recaps/:recapId/share` + - `POST /v1/recaps/:recapId/share-events` +- Travel Sessions + - `POST /v1/travel-sessions`: 서버 기준 여행 모드 시작 + - `PATCH /v1/travel-sessions/:sessionId`: 여행 모드 상태 변경 +- Community + - `POST /v1/travel-rooms`: 방장으로 공동 여행방 생성 + - `GET /v1/travel-rooms/:roomId`: 방장/참여자만 공동 여행방 조회 + - `POST /v1/travel-rooms/:roomId/join`: 신규 참여자는 초대 코드 필요 + - `POST /v1/travel-rooms/:roomId/moments`: 공동 Recap 후보 순간 추가 + - `PATCH /v1/travel-rooms/:roomId/moments/:momentId`: 방장만 후보 상태 변경 + - `POST /v1/travel-rooms/:roomId/moments/:momentId/comments`: 공동 순간 댓글 추가 + - `POST /v1/travel-rooms/:roomId/recaps`: 방장만 공동 Recap 생성 + - `GET /v1/sound-map`: Live Sound Map 핀 조회 + - `POST /v1/sound-map/current-track`: active 여행 세션이 있어야 현재 곡 공개 가능 + - `GET /v1/sound-map/nearby`: 좌표가 없으면 낯선 사용자 핀 없이 빈 목록 반환 + - `GET /v1/music-matches`: 좌표가 없으면 낯선 사용자 매칭 후보 없이 빈 목록 반환 + - `POST /v1/travel-mate-requests`: 공개 매칭 후보에게 동행 요청 + - `PATCH /v1/travel-mate-requests/:requestId`: 수신자/발신자 권한에 따라 요청 상태 변경 + - `POST /v1/community/blocks` + - `POST /v1/community/reports` - Trends + - `GET /v1/trends/regions/:regionCode/sound` + +현재 서버는 스포티파이 재생 제어 API와 `/v1/me/music-platform` API를 제공하지 않습니다. Soundlog는 곡 메타데이터와 외부 링크를 기록/추천/Recap에 연결하는 방식으로 동작합니다. ## Verification diff --git a/docs/ec2-deployment.md b/docs/ec2-deployment.md index ee69c09..911c815 100644 --- a/docs/ec2-deployment.md +++ b/docs/ec2-deployment.md @@ -118,4 +118,4 @@ gh workflow run deploy-ec2.yml --repo SoundLogTeam/SoundLogServer EC2 must already have Docker Engine and Docker Compose v2 installed. The workflow keeps Postgres data in the `postgres_data` Docker volume and uploaded files in the `uploads_data` Docker volume. -After deployment, the API container runs Prisma migrations and upserts the public music catalog used by the frontend, including seeded tracks, playlists, mood recommendations, and regional trends. It does not seed local/test tourist-place fixtures into production. The workflow then verifies `http://127.0.0.1:/v1/health` from inside EC2 and runs the API contract check from inside the deployed API container. It prints EC2 network diagnostics, including Docker port publishing, listening sockets, local health, public-IP self-curl, EC2 metadata network identity, placement region, security groups, and host firewall state. When optional AWS credentials are configured, it opens TCP `` on the configured security group. Finally, it verifies `http://:/v1/health` from GitHub Actions and fails the deploy if the public API port is unreachable. If this gate fails, open TCP `` on the reported EC2 security group/firewall or point `SOUNDLOG_API_ORIGIN` to a reachable API origin before rebuilding the frontend. After the frontend deployment is rebuilt with the synced Vercel env, verify `https://soundlog.shop/api/soundlog/v1/health` and run the app repo's deployed-web check. +After deployment, the API container runs Prisma migrations and upserts the public music catalog used by the frontend, including seeded tracks, playlists, mood recommendations, and regional trends. It does not seed local/test tourist-place fixtures into production. The workflow then verifies `http://127.0.0.1:/v1/health` from inside EC2 and runs the API contract check from inside the deployed API container. It prints EC2 network diagnostics, including Docker port publishing, listening sockets, local health, public-IP self-curl, EC2 metadata network identity, placement region, security groups, and host firewall state. When optional AWS credentials are configured, it opens TCP `` on the configured security group. Finally, it verifies `http://:/v1/health` from GitHub Actions and fails the deploy if the public API port is unreachable. If this gate fails, open TCP `` on the reported EC2 security group/firewall or point `SOUNDLOG_API_ORIGIN` to a reachable API origin before rebuilding the frontend. After the frontend deployment is rebuilt with the synced Vercel env, verify `https://soundlog.shop/api/soundlog/v1/health` and run the app repo's deployed-web check. The app check logs in to protected APIs; set `SOUNDLOG_CHECK_EMAIL` and `SOUNDLOG_CHECK_PASSWORD` to reuse a smoke account, or omit them to let the script create a temporary `@soundlog.test` account. diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index 92bec8c..3430e81 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -9,7 +9,8 @@ info: description: | Soundlog React Native 앱과 백엔드 서버를 연결하기 위한 API 명세입니다. - MVP 범위는 홈 추천, 관광지 조회, 플레이리스트 상세, 순간 로그, 리캡, 보관함, 추천 피드백 수집을 포함합니다. + MVP 범위는 홈 추천, 관광지 조회, 플레이리스트 상세, 순간 로그, 리캡, 보관함, 추천 피드백 수집, + 여행 세션, 공동 여행방, Live Sound Map, 음악 취향 매칭을 포함합니다. 한국관광공사 OpenAPI 원본 응답은 서버에서 Soundlog 도메인 모델로 정규화해서 앱에 전달합니다. servers: - url: http://localhost:4000 @@ -24,7 +25,7 @@ tags: - name: Auth description: 간편 로그인 및 토큰 관리 - name: Me - description: 사용자 프로필과 음악 플랫폼 설정 + description: 사용자 프로필과 로컬 데이터 마이그레이션 - name: Tour description: 관광공사 데이터 기반 장소 컨텍스트 - name: Home @@ -43,6 +44,8 @@ tags: description: 여행 세션 시작/종료 - name: Trends description: 지역 사운드 트렌드 + - name: Community + description: 공동 여행방, Live Sound Map, 취향 매칭, 신고/차단 security: - bearerAuth: [] paths: @@ -214,7 +217,7 @@ paths: operationId: getMe responses: "200": - description: 로그인 사용자, 취향 프로필, 음악 플랫폼 설정 + description: 로그인 사용자와 취향 프로필 요약 content: application/json: schema: @@ -267,7 +270,7 @@ paths: post: tags: - Me - summary: 게스트 로컬 데이터 계정 이관 + summary: 로그인 전 로컬 데이터 계정 이관 description: | 로그인 전 이 기기에 저장된 순간 로그, 좋아요/저장 음악, Recap 초안을 로그인 계정으로 이관합니다. 동일한 `Idempotency-Key`로 재시도해도 중복 저장되지 않아야 합니다. @@ -296,7 +299,6 @@ paths: get: tags: - Tour - security: [] summary: 현재 좌표 주변 관광지 조회 description: | 한국관광공사 위치 기반 관광정보를 Soundlog `PlaceContext`로 정규화해서 반환합니다. @@ -329,6 +331,8 @@ paths: $ref: "#/components/schemas/PlaceContextListResponse" "400": $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" /v1/home/featured-playlists: get: @@ -458,6 +462,62 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + /v1/recommendations/playlists: + get: + tags: + - Playlists + summary: 현재 위치/상태/무드 기반 추천 플레이리스트 조회 + description: | + 앱 홈의 "오늘의 사운드트랙" CTA에서 사용하는 추천 API입니다. + 프론트는 ML 서버를 직접 호출하지 않고 이 API를 통해 Soundlog 서버의 정규화된 플레이리스트 DTO를 받습니다. + operationId: getRecommendedPlaylist + parameters: + - name: x + in: query + required: true + description: 경도 + schema: + type: number + format: double + minimum: -180 + maximum: 180 + example: 126.9963 + - name: y + in: query + required: true + description: 위도 + schema: + type: number + format: double + minimum: -90 + maximum: 90 + example: 37.5104 + - name: state + in: query + required: true + description: 여행 상태 + schema: + $ref: "#/components/schemas/MlTravelState" + example: 산책 + - name: mood + in: query + required: true + description: 원하는 음악 무드 + schema: + $ref: "#/components/schemas/MlMood" + example: 잔잔한 + responses: + "200": + description: 추천 플레이리스트 + content: + application/json: + schema: + $ref: "#/components/schemas/PlaylistCurationResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/playlists/contextual: post: tags: @@ -620,6 +680,7 @@ paths: summary: 순간 로그 생성 description: | 카메라 버튼으로 촬영한 사진, 현재 위치, 현재 장소, 재생 중인 음악, 무드 태그를 하나의 기록으로 저장합니다. + MVP 저장 정책상 사진, 위치, 장소, 곡은 모두 선택값입니다. 음악이나 위치가 없어도 MomentLog는 생성할 수 있습니다. 사진 업로드는 `multipart/form-data`를 기본으로 정의합니다. operationId: createMomentLog parameters: @@ -642,6 +703,123 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + /v1/moment-logs/{momentLogId}: + patch: + tags: + - MomentLogs + summary: 순간 로그 수정 + description: | + 저장된 MomentLog의 메모, 장소, 무드, 곡 스냅샷 같은 편집 가능한 정보를 부분 수정합니다. + 사진 파일 교체는 별도 업로드 정책이 필요하므로 이 API에는 포함하지 않습니다. + operationId: updateMomentLog + parameters: + - name: momentLogId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MomentLogUpdateRequest" + responses: + "200": + description: 수정된 순간 로그 + content: + application/json: + schema: + $ref: "#/components/schemas/MomentLogResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: + - MomentLogs + summary: 순간 로그 삭제 + description: | + 사용자의 MomentLog를 삭제합니다. 공동 여행방 후보가 해당 로그를 참조하고 있으면 후보 자체는 남기고 MomentLog 참조만 해제합니다. + operationId: deleteMomentLog + parameters: + - name: momentLogId + in: path + required: true + schema: + type: string + responses: + "202": + description: 삭제 요청 수락 + content: + application/json: + schema: + $ref: "#/components/schemas/AcceptedResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /v1/moment-logs/{momentLogId}/photo: + put: + tags: + - MomentLogs + summary: 순간 로그 사진 교체 + description: | + 저장된 MomentLog의 사진만 새 업로드 파일로 교체합니다. 서버가 관리하는 기존 로컬 업로드 파일은 best-effort로 정리합니다. + operationId: updateMomentLogPhoto + parameters: + - name: momentLogId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/MomentLogPhotoUpdateForm" + responses: + "200": + description: 사진이 교체된 순간 로그 + content: + application/json: + schema: + $ref: "#/components/schemas/MomentLogResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: + - MomentLogs + summary: 순간 로그 사진 삭제 + description: | + MomentLog 자체는 유지하고 연결된 사진만 제거합니다. 서버가 관리하는 로컬 업로드 파일은 best-effort로 정리합니다. + operationId: deleteMomentLogPhoto + parameters: + - name: momentLogId + in: path + required: true + schema: + type: string + responses: + "200": + description: 사진이 제거된 순간 로그 + content: + application/json: + schema: + $ref: "#/components/schemas/MomentLogResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /v1/recommendation-events: post: tags: @@ -773,10 +951,41 @@ paths: $ref: "#/components/responses/NotFound" /v1/travel-rooms: + get: + tags: + - Community + summary: 내 공동 여행방 목록 조회 + description: 인증된 사용자가 방장 또는 참여자로 속한 공동 여행방을 최근 수정순으로 조회합니다. sessionId를 전달하면 현재 여행 세션의 방을 복구할 수 있습니다. + operationId: getTravelRooms + parameters: + - name: sessionId + in: query + required: false + schema: + type: string + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + responses: + "200": + description: 공동 여행방 목록 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + post: tags: - Community summary: 공동 여행방 생성 + description: 인증된 사용자를 방장(owner)으로 공동 여행방을 생성합니다. operationId: createTravelRoom requestBody: required: true @@ -794,11 +1003,37 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + /v1/travel-rooms/join: + post: + tags: + - Community + summary: 초대 코드로 공동 여행방 참여 + description: roomId 없이 초대 코드만으로 공동 여행방에 참여합니다. 입력한 초대 코드는 서버에서 대문자로 정규화됩니다. + operationId: joinTravelRoomByInviteCode + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomJoinByInviteRequest" + responses: + "200": + description: 참여 후 공동 여행방 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/travel-rooms/{roomId}: get: tags: - Community summary: 공동 여행방 조회 + description: 방장 또는 참여자만 공동 여행방 상세, 멤버, 후보 순간, 댓글을 조회할 수 있습니다. operationId: getTravelRoom parameters: - name: roomId @@ -823,6 +1058,7 @@ paths: tags: - Community summary: 공동 여행방 참여 + description: 이미 참여한 사용자는 바로 방 정보를 받으며, 신규 참여자는 초대 코드가 필요합니다. operationId: joinTravelRoom parameters: - name: roomId @@ -853,6 +1089,7 @@ paths: tags: - Community summary: 공동 Recap 후보 순간 추가 + description: 방장 또는 참여자만 사진/곡/장소가 붙은 공동 Recap 후보 순간을 추가할 수 있습니다. operationId: addTravelRoomMoment parameters: - name: roomId @@ -919,6 +1156,7 @@ paths: tags: - Community summary: 공동 Recap 후보 댓글 추가 + description: 방장 또는 참여자만 공동 순간에 댓글을 남길 수 있습니다. operationId: addTravelRoomMomentComment parameters: - name: roomId @@ -954,6 +1192,7 @@ paths: tags: - Community summary: 공동 Recap 생성 + description: 방장만 공동 Recap을 생성할 수 있으며, accepted 후보 순간이 있으면 accepted만 우선 사용합니다. operationId: createTravelRoomRecap parameters: - name: roomId @@ -985,6 +1224,7 @@ paths: tags: - Community summary: Live Sound Map 핀 조회 + description: 좌표가 있으면 반경 내 핀을 반환합니다. `nearby` 핀은 주변 익명 공개 사용자에게 보이고, `companions` 핀은 내 핀 또는 나와 같은 공동 여행방에 참여한 사용자에게만 보입니다. `private` 핀은 본인에게만 반환됩니다. operationId: getSoundMap parameters: - name: lat @@ -1020,6 +1260,7 @@ paths: tags: - Community summary: 현재 선택 곡을 지도에 공개 + description: 서버에 active 여행 세션이 있는 사용자만 현재 선택 곡과 대략 위치를 지도에 공개할 수 있습니다. visibility를 private으로 보내면 기존 공개 핀을 숨깁니다. operationId: upsertSoundMapCurrentTrack requestBody: required: true @@ -1044,6 +1285,7 @@ paths: tags: - Community summary: 주변 익명 사운드 핀 조회 + description: 위치 권한 없이 호출하면 낯선 사용자 핀을 노출하지 않고 빈 목록을 반환합니다. operationId: getNearbySounds parameters: - name: lat @@ -1082,6 +1324,7 @@ paths: tags: - Community summary: 주변 음악 취향 매칭 후보 조회 + description: 위치 권한 없이 호출하면 낯선 사용자 매칭 후보를 노출하지 않고 빈 목록을 반환합니다. operationId: getMusicMatches parameters: - name: lat @@ -1116,10 +1359,57 @@ paths: $ref: "#/components/responses/Unauthorized" /v1/travel-mate-requests: + get: + tags: + - Community + summary: 동행 매칭 요청 목록 조회 + description: 받은 요청함, 보낸 요청함, 전체 요청을 조회합니다. 수락 전에는 연락처와 정확한 위치가 응답에 포함되지 않습니다. + operationId: getTravelMateRequests + parameters: + - name: box + in: query + schema: + type: string + enum: + - all + - inbox + - sent + default: all + - name: status + in: query + schema: + type: string + enum: + - pending + - accepted + - declined + - expired + - cancelled + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + responses: + "200": + description: 동행 매칭 요청 목록 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelMateRequestListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + post: tags: - Community summary: 동행 매칭 요청 생성 + description: > + 대상 사용자가 주변 익명 공개 핀을 유지 중일 때만 요청을 생성합니다. + pending/accepted 요청은 기존 요청을 재사용하고, 거절/취소/만료된 같은 대상 요청은 24시간 동안 재요청을 제한합니다. + 서로 차단한 관계에서는 요청을 생성할 수 없습니다. operationId: createTravelMateRequest requestBody: required: true @@ -1138,12 +1428,15 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /v1/travel-mate-requests/{requestId}: patch: tags: - Community summary: 동행 매칭 요청 상태 변경 + description: 수신자는 accept/decline, 발신자는 cancel, 시스템 또는 운영 도구는 expire 액션을 사용할 수 있습니다. operationId: updateTravelMateRequest parameters: - name: requestId @@ -1249,6 +1542,7 @@ paths: tags: - TravelSessions summary: 여행 세션 상태 변경 + description: 세션을 ended로 변경하면 해당 세션에서 공개 중이던 사운드맵 핀은 즉시 private 상태로 전환되고 만료됩니다. operationId: updateTravelSession parameters: - name: sessionId @@ -1280,9 +1574,8 @@ paths: get: tags: - Trends - security: [] summary: 지역 사운드 트렌드 조회 - description: 초기 사용자 또는 비로그인 사용자에게 제공할 지역 기반 인기 음악/무드 집계입니다. + description: 로그인 사용자에게 제공할 지역 기반 인기 음악/무드 집계입니다. operationId: getRegionSoundTrend parameters: - name: regionCode @@ -1308,6 +1601,8 @@ paths: application/json: schema: $ref: "#/components/schemas/RegionSoundTrendResponse" + "401": + $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" @@ -1582,6 +1877,26 @@ components: - active - local + MlTravelState: + type: string + description: ML 추천 서버와 맞춘 여행 상태 + enum: + - 바다 + - 드라이브 + - 산책 + - 카페 + - 야경 + + MlMood: + type: string + description: ML 추천 서버와 맞춘 음악 무드 + enum: + - 잔잔한 + - 신나는 + - 시원한 + - 설레는 + - 감성적인 + PlaceContext: type: object required: @@ -1783,6 +2098,42 @@ components: context: $ref: "#/components/schemas/RecommendationContext" + LibraryPlaylistSummary: + type: object + required: + - id + - regionName + - trackCount + - durationText + properties: + id: + type: string + example: busan-ocean + regionName: + type: string + example: 부산 + placeName: + type: string + example: 광안리 해변 + description: + type: string + example: 부산광역시에 어울리는 노래를 추천해드립니다. + reason: + type: string + example: 부산 해변 산책과 어울리는 음악이에요 + coverImageUrl: + type: string + format: uri + backgroundImageUrl: + type: string + format: uri + trackCount: + type: integer + example: 5 + durationText: + type: string + example: 40:00분 + MusicLogItem: type: object required: @@ -1901,6 +2252,14 @@ components: placeName: type: string example: 광안리해수욕장 + source: + type: string + description: 추천 산출 경로입니다. ML 추천이 실패했거나 위치가 없으면 seed-fallback으로 내려갑니다. + enum: + - ml-recommendation + - seed-fallback + - server-contextual + example: ml-recommendation topFilter: type: string example: 전체 @@ -1916,8 +2275,13 @@ components: type: string travelMode: $ref: "#/components/schemas/TravelMode" + state: + $ref: "#/components/schemas/MlTravelState" + mood: + $ref: "#/components/schemas/MlMood" moodTags: type: array + description: 선택한 무드 태그입니다. 사용자가 무드를 고르지 않으면 빈 배열로 저장될 수 있습니다. items: $ref: "#/components/schemas/MoodTag" preferredGenres: @@ -1978,6 +2342,9 @@ components: format: date-time playlistId: type: string + description: 이 곡이 저장될 때 연결된 추천 플레이리스트 ID입니다. + playlist: + $ref: "#/components/schemas/LibraryPlaylistSummary" kind: type: string enum: @@ -1989,14 +2356,13 @@ components: MomentLogCreateForm: type: object required: - - photo - createdAt - moodTags properties: photo: type: string format: binary - description: 카메라 촬영 이미지 파일 + description: 카메라 촬영 이미지 파일. 사진 없이 음악/장소 로그만 저장할 때는 생략할 수 있습니다. createdAt: type: string format: date-time @@ -2012,6 +2378,10 @@ components: type: string placeName: type: string + note: + type: string + maxLength: 240 + description: 사용자가 Moment 저장 시 남긴 짧은 메모 placeCategory: type: string trackId: @@ -2027,11 +2397,67 @@ components: items: $ref: "#/components/schemas/MoodTag" + MomentLogPhotoUpdateForm: + type: object + required: + - photo + properties: + photo: + type: string + format: binary + description: 새 Moment 사진 파일 + + MomentLogUpdateRequest: + type: object + description: MomentLog 부분 수정 요청. 전달하지 않은 필드는 기존 값을 유지합니다. + properties: + createdAt: + type: string + format: date-time + sessionId: + type: string + nullable: true + lat: + type: number + format: double + nullable: true + lng: + type: number + format: double + nullable: true + placeId: + type: string + nullable: true + placeName: + type: string + nullable: true + note: + type: string + nullable: true + maxLength: 240 + description: null을 보내면 기존 메모를 비웁니다. + placeCategory: + type: string + nullable: true + trackId: + type: string + trackTitle: + type: string + artistName: + type: string + travelMode: + allOf: + - $ref: "#/components/schemas/TravelMode" + nullable: true + moodTags: + type: array + items: + $ref: "#/components/schemas/MoodTag" + MomentLog: type: object required: - id - - photoUrl - createdAt - moodTags - syncStatus @@ -2055,6 +2481,9 @@ components: type: string placeName: type: string + note: + type: string + maxLength: 240 track: $ref: "#/components/schemas/Track" travelMode: @@ -2079,11 +2508,16 @@ components: type: string enum: - track_external_open + - external_music_open_failed + - track_selected - track_like - track_unlike - track_save - track_unsave + - moment_log_saved + - moment_log_sync_failed - playlist_open + - mood_adjusted - mood_filter_change - live_track_shared - nearby_sound_opened @@ -2299,6 +2733,22 @@ components: maxLength: 16 example: A1B2C3D4 + TravelRoomJoinByInviteRequest: + type: object + required: + - inviteCode + properties: + displayName: + type: string + minLength: 1 + maxLength: 40 + example: 수경 + inviteCode: + type: string + minLength: 4 + maxLength: 16 + example: A1B2C3D4 + TravelRoomMomentCreateRequest: type: object properties: @@ -2739,6 +3189,16 @@ components: data: $ref: "#/components/schemas/TravelRoom" + TravelRoomListResponse: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/TravelRoom" + TravelRoomMomentResponse: type: object required: @@ -2791,6 +3251,16 @@ components: data: $ref: "#/components/schemas/TravelMateRequest" + TravelMateRequestListResponse: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/TravelMateRequest" + TravelSessionCreateRequest: type: object properties: diff --git a/package.json b/package.json index 587336e..27ab3f7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit", "test": "vitest", "test:api": "vitest run", + "test:coverage": "vitest run --coverage", "check:no-spotify-metadata": "node scripts/check-no-spotify-metadata.mjs", "check:public-api-contract": "node scripts/check-public-api-contract.mjs", "check:production-env": "node scripts/check-production-env.mjs", @@ -45,6 +46,7 @@ "@types/node": "^22.19.0", "@types/supertest": "^7.2.0", "@types/swagger-ui-express": "^4.1.8", + "@vitest/coverage-v8": "^4.1.8", "prisma": "^6.19.0", "supertest": "^7.1.4", "tsx": "^4.20.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45af279..7fb45b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: '@types/swagger-ui-express': specifier: ^4.1.8 version: 4.1.8 + '@vitest/coverage-v8': + specifier: ^4.1.8 + version: 4.1.8(vitest@4.1.8) prisma: specifier: ^6.19.0 version: 6.19.3(typescript@5.9.3) @@ -87,10 +90,31 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.15 - version: 4.1.8(@types/node@22.19.20)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) + version: 4.1.8(@types/node@22.19.20)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) packages: + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -256,9 +280,16 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -478,6 +509,15 @@ packages: '@types/swagger-ui-express@4.1.8': resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + peerDependencies: + '@vitest/browser': 4.1.8 + vitest: 4.1.8 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.8': resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} @@ -521,6 +561,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -804,6 +847,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -820,6 +867,9 @@ packages: resolution: {integrity: sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==} engines: {node: '>=18.0.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -838,10 +888,25 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -946,6 +1011,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1201,6 +1273,10 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + swagger-ui-dist@5.32.8: resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==} @@ -1364,6 +1440,21 @@ packages: snapshots: + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1458,8 +1549,15 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1664,6 +1762,20 @@ snapshots: '@types/express': 5.0.6 '@types/serve-static': 2.2.0 + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.2 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@22.19.20)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) + '@vitest/expect@4.1.8': dependencies: '@standard-schema/spec': 1.1.0 @@ -1716,6 +1828,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + asynckit@0.4.0: {} basic-auth@2.0.1: @@ -2038,6 +2156,8 @@ snapshots: gopd@1.2.0: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -2050,6 +2170,8 @@ snapshots: helmet@8.2.0: {} + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -2068,8 +2190,23 @@ snapshots: is-promise@4.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} + js-tokens@10.0.0: {} + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -2161,6 +2298,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.2 + math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -2438,6 +2585,10 @@ snapshots: transitivePeerDependencies: - supports-color + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + swagger-ui-dist@5.32.8: dependencies: '@scarf/scarf': 1.4.0 @@ -2506,7 +2657,7 @@ snapshots: jiti: 2.7.0 tsx: 4.22.4 - vitest@4.1.8(@types/node@22.19.20)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)): + vitest@4.1.8(@types/node@22.19.20)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) @@ -2530,6 +2681,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.20 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) transitivePeerDependencies: - msw diff --git a/prisma/migrations/20260708001000_moment_log_note/migration.sql b/prisma/migrations/20260708001000_moment_log_note/migration.sql new file mode 100644 index 0000000..bb98e3f --- /dev/null +++ b/prisma/migrations/20260708001000_moment_log_note/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "MomentLog" ADD COLUMN "note" TEXT; diff --git a/prisma/migrations/20260708003000_moment_log_optional_photo/migration.sql b/prisma/migrations/20260708003000_moment_log_optional_photo/migration.sql new file mode 100644 index 0000000..ff813a0 --- /dev/null +++ b/prisma/migrations/20260708003000_moment_log_optional_photo/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "MomentLog" ALTER COLUMN "photoUrl" DROP NOT NULL; diff --git a/prisma/models/travel.prisma b/prisma/models/travel.prisma index e25f10c..0426264 100644 --- a/prisma/models/travel.prisma +++ b/prisma/models/travel.prisma @@ -17,7 +17,7 @@ model Place { model MomentLog { id String @id userId String - photoUrl String + photoUrl String? createdAt DateTime sessionId String? lat Float? @@ -25,6 +25,7 @@ model MomentLog { placeCategory String? placeId String? placeName String? + note String? trackSnapshot Json? travelMode String? moodTags String[] diff --git a/prisma/seed.ts b/prisma/seed.ts index 8436d97..e5d033d 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -209,6 +209,7 @@ export async function seedDatabase() { createdAt: new Date(log.createdAt), sessionId: log.sessionId, placeName: log.placeName, + note: log.note, moodTags: [...log.moodTags], source: 'camera', syncStatus: 'synced', diff --git a/src/constants/error.constants.ts b/src/constants/error.constants.ts index 9f963fa..4b796f7 100644 --- a/src/constants/error.constants.ts +++ b/src/constants/error.constants.ts @@ -31,8 +31,10 @@ export const ERROR_MESSAGES = { ROUTE_NOT_FOUND: '요청한 API 경로를 찾을 수 없습니다.', TRACK_NOT_FOUND: '트랙을 찾을 수 없습니다.', TRAVEL_MATE_REQUEST_ACTION_NOT_ALLOWED: '이 동행 매칭 요청을 처리할 권한이 없습니다.', + TRAVEL_MATE_REQUEST_BLOCKED: '차단 관계의 사용자에게는 동행 매칭 요청을 보낼 수 없습니다.', TRAVEL_MATE_REQUEST_NOT_PENDING: '대기 중인 동행 매칭 요청만 변경할 수 있습니다.', TRAVEL_MATE_REQUEST_NOT_FOUND: '동행 매칭 요청을 찾을 수 없습니다.', + TRAVEL_MATE_REQUEST_RATE_LIMITED: '최근 처리된 동행 매칭 요청이 있어 잠시 후 다시 시도할 수 있습니다.', TRAVEL_MATE_SELF_REQUEST_NOT_ALLOWED: '자기 자신에게는 동행 매칭 요청을 보낼 수 없습니다.', TRAVEL_MATE_TARGET_REQUIRED: '동행 매칭 대상이 필요합니다.', TRAVEL_ROOM_INVITE_CODE_INVALID: '여행방 초대 코드가 올바르지 않습니다.', diff --git a/src/controllers/community.controller.ts b/src/controllers/community.controller.ts index 1f015f1..21e559e 100644 --- a/src/controllers/community.controller.ts +++ b/src/controllers/community.controller.ts @@ -5,6 +5,11 @@ import { apiService } from '../services/api.service.js'; import { acceptedResponse, dataResponse } from '../utils/response.js'; export const communityController = { + async getTravelRooms(req: Request, res: Response) { + const user = requireUser(req); + res.json(dataResponse(await apiService.getTravelRooms(user.id, req.query))); + }, + async createTravelRoom(req: Request, res: Response) { const user = requireUser(req); res.status(201).json(dataResponse(await apiService.createTravelRoom(user.id, req.body))); @@ -24,6 +29,11 @@ export const communityController = { ); }, + async joinTravelRoomByInviteCode(req: Request, res: Response) { + const user = requireUser(req); + res.json(dataResponse(await apiService.joinTravelRoomByInviteCode(user.id, req.body))); + }, + async addTravelRoomMoment(req: Request, res: Response) { const user = requireUser(req); res.status(201).json( @@ -100,6 +110,11 @@ export const communityController = { res.status(201).json(dataResponse(await apiService.createTravelMateRequest(user.id, req.body))); }, + async getTravelMateRequests(req: Request, res: Response) { + const user = requireUser(req); + res.json(dataResponse(await apiService.getTravelMateRequests(user.id, req.query))); + }, + async updateTravelMateRequest(req: Request, res: Response) { const user = requireUser(req); res.json( diff --git a/src/controllers/moment-log.controller.ts b/src/controllers/moment-log.controller.ts index 06d28fb..d11a478 100644 --- a/src/controllers/moment-log.controller.ts +++ b/src/controllers/moment-log.controller.ts @@ -5,7 +5,7 @@ import { requireUser } from '../middlewares/auth.middleware.js'; import { createUploadedFilePublicPath } from '../middlewares/upload.middleware.js'; import { apiService } from '../services/api.service.js'; import { badRequest } from '../utils/http-error.js'; -import { dataResponse } from '../utils/response.js'; +import { acceptedResponse, dataResponse } from '../utils/response.js'; export const momentLogController = { async getMomentLogs(req: Request, res: Response) { @@ -15,10 +15,9 @@ export const momentLogController = { async createMomentLog(req: Request, res: Response) { const user = requireUser(req); - - if (!req.file) { - throw badRequest(ERROR_MESSAGES.PHOTO_REQUIRED); - } + const photoPath = req.file + ? createUploadedFilePublicPath(req.file.filename) + : undefined; res.status(201).json( dataResponse( @@ -26,11 +25,63 @@ export const momentLogController = { user.id, { ...req.body, - photoPath: createUploadedFilePublicPath(req.file.filename), + photoPath, }, req.header('Idempotency-Key'), ), ), ); }, + + async updateMomentLog(req: Request, res: Response) { + const user = requireUser(req); + + res.json( + dataResponse( + await apiService.updateMomentLog( + user.id, + String(req.params.momentLogId), + req.body, + ), + ), + ); + }, + + async updateMomentLogPhoto(req: Request, res: Response) { + const user = requireUser(req); + + if (!req.file) { + throw badRequest(ERROR_MESSAGES.PHOTO_REQUIRED); + } + + res.json( + dataResponse( + await apiService.updateMomentLogPhoto( + user.id, + String(req.params.momentLogId), + createUploadedFilePublicPath(req.file.filename), + ), + ), + ); + }, + + async deleteMomentLogPhoto(req: Request, res: Response) { + const user = requireUser(req); + + res.json( + dataResponse( + await apiService.deleteMomentLogPhoto( + user.id, + String(req.params.momentLogId), + ), + ), + ); + }, + + async deleteMomentLog(req: Request, res: Response) { + const user = requireUser(req); + + await apiService.deleteMomentLog(user.id, String(req.params.momentLogId)); + res.status(202).json(acceptedResponse()); + }, }; diff --git a/src/controllers/playlist.controller.ts b/src/controllers/playlist.controller.ts index 146d809..a369e3d 100644 --- a/src/controllers/playlist.controller.ts +++ b/src/controllers/playlist.controller.ts @@ -4,6 +4,13 @@ import { requireUser } from '../middlewares/auth.middleware.js'; import { apiService } from '../services/api.service.js'; import { dataResponse } from '../utils/response.js'; +type RecommendationPlaylistQuery = { + mood: '감성적인' | '설레는' | '시원한' | '신나는' | '잔잔한'; + state: '바다' | '드라이브' | '산책' | '카페' | '야경'; + x: number; + y: number; +}; + export const playlistController = { async createContextualPlaylist(req: Request, res: Response) { const user = requireUser(req); @@ -18,6 +25,23 @@ export const playlistController = { ); }, + async getRecommendedPlaylist(req: Request, res: Response) { + const query = req.query as unknown as RecommendationPlaylistQuery; + + res.json( + dataResponse( + await apiService.getRecommendedPlaylist(req.user?.id, { + location: { + lat: query.y, + lng: query.x, + }, + mood: query.mood, + state: query.state, + }), + ), + ); + }, + async getPlaylist(req: Request, res: Response) { res.json( dataResponse( diff --git a/src/data/seed-data.ts b/src/data/seed-data.ts index 1f2ee43..2233124 100644 --- a/src/data/seed-data.ts +++ b/src/data/seed-data.ts @@ -212,6 +212,7 @@ export const seedMomentLogs = [ photoUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', createdAt: '2026-05-25T00:00:00.000Z', placeName: '광안리', + note: '바다 앞에서 처음 저장한 사운드', trackId: 'seoul-city', sessionId: 'seed-session', moodTags: ['fresh'], @@ -221,6 +222,7 @@ export const seedMomentLogs = [ photoUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', createdAt: '2026-05-25T00:10:00.000Z', placeName: '한강', + note: '강변 산책에 어울린 잔잔한 곡', trackId: 'night-letter', sessionId: 'seed-session', moodTags: ['calm'], @@ -230,6 +232,7 @@ export const seedMomentLogs = [ photoUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', createdAt: '2026-05-25T00:20:00.000Z', placeName: '부산', + note: '여행 에너지가 올라간 순간', trackId: 'seoul-night-track', sessionId: 'seed-session', moodTags: ['active'], diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 3e7148b..8ddceb6 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -80,20 +80,6 @@ export async function authMiddleware(req: Request, _res: Response, next: NextFun } } -export async function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction) { - const authorization = req.header('authorization'); - const token = authorization?.startsWith('Bearer ') - ? authorization.slice('Bearer '.length) - : undefined; - - if (!token) { - next(); - return; - } - - await authMiddleware(req, res, next); -} - export function requireUser(req: Request) { if (!req.user) { throw unauthorized(); diff --git a/src/mock/mock-db.ts b/src/mock/mock-db.ts index e25a1de..4d36140 100644 --- a/src/mock/mock-db.ts +++ b/src/mock/mock-db.ts @@ -25,10 +25,11 @@ type MockMomentLog = { lat?: number; lng?: number; moodTags: string[]; - photoUrl: string; + photoUrl?: string; placeCategory?: string; placeId?: string; placeName?: string; + note?: string; sessionId?: string; source: 'camera'; syncStatus: 'failed' | 'pending' | 'synced'; @@ -230,6 +231,7 @@ function createMockDb() { createdAt: new Date(log.createdAt), sessionId: log.sessionId, placeName: log.placeName, + note: log.note, moodTags: [...log.moodTags], source: 'camera' as const, syncStatus: 'synced' as const, diff --git a/src/routes/index.ts b/src/routes/index.ts index 173e0d2..aba249e 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -17,7 +17,7 @@ import { trendController, } from '../controllers/index.js'; import { asyncHandler } from '../utils/async-handler.js'; -import { authMiddleware, optionalAuthMiddleware } from '../middlewares/auth.middleware.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; import { momentPhotoUpload } from '../middlewares/upload.middleware.js'; import { validate } from '../middlewares/validate.middleware.js'; import { @@ -84,19 +84,20 @@ export function createApiRouter() { router.get( '/v1/tour/nearby-places', + authMiddleware, validate({ query: tourValidators.nearbyQuery }), asyncHandler(tourController.getNearbyPlaces), ); router.get( '/v1/home/featured-playlists', - optionalAuthMiddleware, + authMiddleware, validate({ query: homeValidators.featuredQuery }), asyncHandler(homeController.getFeaturedPlaylists), ); router.get( '/v1/home/mood-recommendations', - optionalAuthMiddleware, + authMiddleware, validate({ query: homeValidators.moodQuery }), asyncHandler(homeController.getMoodRecommendations), ); @@ -113,9 +114,15 @@ export function createApiRouter() { validate({ body: playlistValidators.contextualBody }), asyncHandler(playlistController.createContextualPlaylist), ); + router.get( + '/v1/recommendations/playlists', + authMiddleware, + validate({ query: playlistValidators.recommendationQuery }), + asyncHandler(playlistController.getRecommendedPlaylist), + ); router.get( '/v1/playlists/:playlistId', - optionalAuthMiddleware, + authMiddleware, validate({ params: playlistValidators.detailParams, query: playlistValidators.detailQuery, @@ -152,6 +159,34 @@ export function createApiRouter() { validate({ body: momentLogValidators.createBody }), asyncHandler(momentLogController.createMomentLog), ); + router.patch( + '/v1/moment-logs/:momentLogId', + authMiddleware, + validate({ + params: momentLogValidators.momentLogParams, + body: momentLogValidators.updateBody, + }), + asyncHandler(momentLogController.updateMomentLog), + ); + router.put( + '/v1/moment-logs/:momentLogId/photo', + authMiddleware, + momentPhotoUpload.single('photo'), + validate({ params: momentLogValidators.momentLogParams }), + asyncHandler(momentLogController.updateMomentLogPhoto), + ); + router.delete( + '/v1/moment-logs/:momentLogId/photo', + authMiddleware, + validate({ params: momentLogValidators.momentLogParams }), + asyncHandler(momentLogController.deleteMomentLogPhoto), + ); + router.delete( + '/v1/moment-logs/:momentLogId', + authMiddleware, + validate({ params: momentLogValidators.momentLogParams }), + asyncHandler(momentLogController.deleteMomentLog), + ); router.post( '/v1/recommendation-events', @@ -194,6 +229,18 @@ export function createApiRouter() { validate({ body: communityValidators.createRoomBody }), asyncHandler(communityController.createTravelRoom), ); + router.get( + '/v1/travel-rooms', + authMiddleware, + validate({ query: communityValidators.listRoomsQuery }), + asyncHandler(communityController.getTravelRooms), + ); + router.post( + '/v1/travel-rooms/join', + authMiddleware, + validate({ body: communityValidators.joinRoomByInviteBody }), + asyncHandler(communityController.joinTravelRoomByInviteCode), + ); router.get( '/v1/travel-rooms/:roomId', authMiddleware, @@ -270,6 +317,12 @@ export function createApiRouter() { validate({ query: communityValidators.musicMatchesQuery }), asyncHandler(communityController.getMusicMatches), ); + router.get( + '/v1/travel-mate-requests', + authMiddleware, + validate({ query: communityValidators.listMateRequestsQuery }), + asyncHandler(communityController.getTravelMateRequests), + ); router.post( '/v1/travel-mate-requests', authMiddleware, @@ -316,6 +369,7 @@ export function createApiRouter() { router.get( '/v1/trends/regions/:regionCode/sound', + authMiddleware, validate({ params: trendValidators.params, query: trendValidators.query, diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index c43b6db..6132f1e 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -17,12 +17,38 @@ type TrackDto = { title: string; }; +type MomentLogUpdateInput = { + artistName?: string; + createdAt?: string; + lat?: number | null; + lng?: number | null; + moodTags?: string[]; + note?: string | null; + placeCategory?: string | null; + placeId?: string | null; + placeName?: string | null; + sessionId?: string | null; + trackId?: string; + trackTitle?: string; + travelMode?: string | null; +}; + +const TRAVEL_MATE_REQUEST_COOLDOWN_MS = 24 * 60 * 60 * 1000; +const CLOSED_TRAVEL_MATE_REQUEST_STATUSES = ['cancelled', 'declined', 'expired']; + function compact>(value: T) { return Object.fromEntries( Object.entries(value).filter(([, item]) => item !== undefined && item !== null), ) as Partial; } +function hasOwn( + value: T, + key: K, +): value is T & Record { + return Object.prototype.hasOwnProperty.call(value, key); +} + function trackToDto( track: NonNullable>, state?: { isLiked?: boolean; isSaved?: boolean }, @@ -95,6 +121,51 @@ function playlistToDto(playlist: (typeof mockDb.playlists)[number]) { }); } +type MockPlaylistSummarySource = { + backgroundImageUrl?: string; + coverImageUrl?: string; + description?: string; + durationText: string; + id: string; + placeName?: string; + reason?: string; + regionName: string; + trackCount?: number; + trackIds?: string[]; +}; + +function playlistSummaryToDto(playlist: MockPlaylistSummarySource) { + return compact({ + id: playlist.id, + regionName: playlist.regionName, + placeName: playlist.placeName, + description: playlist.description, + reason: playlist.reason, + coverImageUrl: playlist.coverImageUrl, + backgroundImageUrl: playlist.backgroundImageUrl, + trackCount: playlist.trackCount ?? playlist.trackIds?.length ?? 0, + durationText: playlist.durationText, + }); +} + +function withPlaylistContext>( + playlist: T, + context: Record, +) { + const currentContext = + playlist.context && typeof playlist.context === 'object' + ? (playlist.context as Record) + : {}; + + return { + ...playlist, + context: compact({ + ...currentContext, + ...context, + }), + }; +} + function momentLogToDto(log: (typeof mockDb.momentLogs)[number]) { return compact({ id: log.id, @@ -109,6 +180,7 @@ function momentLogToDto(log: (typeof mockDb.momentLogs)[number]) { placeCategory: log.placeCategory, placeId: log.placeId, placeName: log.placeName, + note: log.note, track: log.trackSnapshot, travelMode: log.travelMode, moodTags: log.moodTags, @@ -217,6 +289,36 @@ function getMockUserProfile(userId: string) { }; } +function getMockCompanionUserIds(userId: string) { + const roomIds = mockDb.travelRoomMembers + .filter((member) => member.userId === userId) + .map((member) => member.roomId); + + return Array.from( + new Set( + mockDb.travelRoomMembers + .filter((member) => roomIds.includes(member.roomId)) + .map((member) => member.userId), + ), + ); +} + +function getMockCommunityHiddenUserIds(userId: string) { + return Array.from(new Set( + mockDb.communityBlocks + .filter((block) => block.blockerId === userId || block.blockedUserId === userId) + .map((block) => (block.blockerId === userId ? block.blockedUserId : block.blockerId)), + )); +} + +function hasMockCommunityBlockBetween(userId: string, targetUserId: string) { + return mockDb.communityBlocks.some( + (block) => + (block.blockerId === userId && block.blockedUserId === targetUserId) || + (block.blockerId === targetUserId && block.blockedUserId === userId), + ); +} + function roomToDto(room: (typeof mockDb.travelRooms)[number]) { const members = mockDb.travelRoomMembers.filter((member) => member.roomId === room.id); const moments = mockDb.travelRoomMoments @@ -601,7 +703,35 @@ export const mockSoundlogService = { throw notFound(ERROR_MESSAGES.PLAYLIST_NOT_FOUND); } - return playlistToDto(playlist); + return withPlaylistContext(playlistToDto(playlist), { + mood: input.mood, + moodTags: input.moodTags, + placeId: input.placeId, + source: 'seed-fallback', + state: input.state, + travelMode: input.travelMode, + }); + }, + + async getRecommendedPlaylist(_userId: string | undefined, input: { + location?: { lat: number; lng: number }; + mood?: string; + state?: string; + }) { + const playlistId = getDefaultPlaylistId({ + lat: input.location?.lat, + }); + const playlist = mockDb.playlists.find((item) => item.id === playlistId); + + if (!playlist) { + throw notFound(ERROR_MESSAGES.PLAYLIST_NOT_FOUND); + } + + return withPlaylistContext(playlistToDto(playlist), { + mood: input.mood, + source: 'seed-fallback', + state: input.state, + }); }, async getPlaylist(_userId: string | undefined, playlistId: string, query: { lat?: number; placeId?: string }) { @@ -612,7 +742,11 @@ export const mockSoundlogService = { throw notFound(ERROR_MESSAGES.PLAYLIST_NOT_FOUND); } - return playlistToDto(playlist); + const dto = playlistToDto(playlist); + + return playlistId === 'fallback' + ? withPlaylistContext(dto, { source: 'seed-fallback' }) + : dto; }, async getLibraryTracks(_userId: string, params: { @@ -643,6 +777,17 @@ export const mockSoundlogService = { id: state.trackId, createdAt: (state.isLiked ? state.likedAt : state.savedAt)?.toISOString() ?? state.updatedAt.toISOString(), playlistId: state.playlistId, + playlist: state.playlistId + ? playlistSummaryToDto( + mockDb.playlists.find((playlist) => playlist.id === state.playlistId) ?? { + id: state.playlistId, + regionName: state.playlistId, + reason: '', + trackIds: [], + durationText: '', + }, + ) + : undefined, kind: state.isLiked ? 'liked' : 'saved', track: trackToDto(track, state), }; @@ -731,10 +876,11 @@ export const mockSoundlogService = { lat?: number; lng?: number; moodTags: string[]; - photoPath: string; + photoPath?: string; placeCategory?: string; placeId?: string; placeName?: string; + note?: string; sessionId?: string; trackId?: string; trackTitle?: string; @@ -746,7 +892,9 @@ export const mockSoundlogService = { const track = findMockTrack(input.trackId); const log = { id: createPublicId('moment'), - photoUrl: `${env.UPLOAD_PUBLIC_BASE_URL}${input.photoPath}`, + photoUrl: input.photoPath + ? `${env.UPLOAD_PUBLIC_BASE_URL}${input.photoPath}` + : undefined, createdAt: new Date(input.createdAt), sessionId: input.sessionId, lat: input.lat, @@ -754,6 +902,7 @@ export const mockSoundlogService = { placeCategory: input.placeCategory, placeId: input.placeId, placeName: input.placeName, + note: input.note, trackSnapshot: track ?? (input.trackTitle @@ -776,6 +925,116 @@ export const mockSoundlogService = { ); }, + async updateMomentLog( + _userId: string, + momentLogId: string, + input: MomentLogUpdateInput, + ) { + const log = mockDb.momentLogs.find((moment) => moment.id === momentLogId); + + if (!log) { + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + + if (input.createdAt) { + log.createdAt = new Date(input.createdAt); + } + + if (hasOwn(input, 'lat')) { + log.lat = input.lat ?? undefined; + } + + if (hasOwn(input, 'lng')) { + log.lng = input.lng ?? undefined; + } + + if (input.moodTags) { + log.moodTags = [...input.moodTags]; + } + + if (hasOwn(input, 'note')) { + log.note = input.note ?? undefined; + } + + if (hasOwn(input, 'placeCategory')) { + log.placeCategory = input.placeCategory ?? undefined; + } + + if (hasOwn(input, 'placeId')) { + log.placeId = input.placeId ?? undefined; + } + + if (hasOwn(input, 'placeName')) { + log.placeName = input.placeName ?? undefined; + } + + if (hasOwn(input, 'sessionId')) { + log.sessionId = input.sessionId ?? undefined; + } + + if (hasOwn(input, 'travelMode')) { + log.travelMode = input.travelMode ?? undefined; + } + + const shouldUpdateTrackSnapshot = + hasOwn(input, 'trackId') || + hasOwn(input, 'trackTitle') || + hasOwn(input, 'artistName'); + + if (shouldUpdateTrackSnapshot) { + log.trackSnapshot = + findMockTrack(input.trackId) ?? + (input.trackId || input.trackTitle + ? { + id: input.trackId ?? createPublicId('track'), + title: input.trackTitle ?? '선택한 음악', + artist: input.artistName ?? '아티스트 미상', + } + : undefined); + } + + return momentLogToDto(log); + }, + + async updateMomentLogPhoto(_userId: string, momentLogId: string, photoPath: string) { + const log = mockDb.momentLogs.find((moment) => moment.id === momentLogId); + + if (!log) { + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + + log.photoUrl = `${env.UPLOAD_PUBLIC_BASE_URL}${photoPath}`; + + return momentLogToDto(log); + }, + + async deleteMomentLogPhoto(_userId: string, momentLogId: string) { + const log = mockDb.momentLogs.find((moment) => moment.id === momentLogId); + + if (!log) { + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + + log.photoUrl = undefined; + + return momentLogToDto(log); + }, + + async deleteMomentLog(_userId: string, momentLogId: string) { + const index = mockDb.momentLogs.findIndex((moment) => moment.id === momentLogId); + + if (index === -1) { + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + + mockDb.momentLogs.splice(index, 1); + mockDb.travelRoomMoments.forEach((moment) => { + if (moment.momentLogId === momentLogId) { + moment.momentLogId = undefined; + } + }); + }, + async createRecommendationEvents(userId: string, input: { events: Array<{ context: Record; @@ -834,6 +1093,24 @@ export const mockSoundlogService = { return roomToDto(room); }, + async getTravelRooms(userId: string, query: { + limit?: number; + sessionId?: string; + }) { + const roomIds = new Set( + mockDb.travelRoomMembers + .filter((member) => member.userId === userId) + .map((member) => member.roomId), + ); + + return mockDb.travelRooms + .filter((room) => roomIds.has(room.id)) + .filter((room) => !query.sessionId || room.sessionId === query.sessionId) + .sort((first, second) => second.updatedAt.getTime() - first.updatedAt.getTime()) + .slice(0, getLimit(query.limit)) + .map(roomToDto); + }, + async getTravelRoom(userId: string, roomId: string) { const room = mockDb.travelRooms.find((item) => item.id === roomId); const isMember = mockDb.travelRoomMembers.some( @@ -881,6 +1158,7 @@ export const mockSoundlogService = { userId, }); } + room.updatedAt = new Date(); recordMockCommunityRecommendationEvent(userId, 'trip_room_joined', { role: existing?.role ?? 'member', @@ -890,6 +1168,19 @@ export const mockSoundlogService = { return roomToDto(room); }, + async joinTravelRoomByInviteCode(userId: string, input: { + displayName?: string; + inviteCode: string; + }) { + const room = mockDb.travelRooms.find((item) => item.inviteCode === input.inviteCode); + + if (!room) { + throw badRequest(ERROR_MESSAGES.TRAVEL_ROOM_INVITE_CODE_INVALID); + } + + return this.joinTravelRoom(userId, room.id, input); + }, + async addTravelRoomMoment(userId: string, roomId: string, input: { artistName?: string; momentLogId?: string; @@ -933,6 +1224,10 @@ export const mockSoundlogService = { userId, }; mockDb.travelRoomMoments.push(moment); + const roomToTouch = mockDb.travelRooms.find((item) => item.id === roomId); + if (roomToTouch) { + roomToTouch.updatedAt = new Date(); + } recordMockCommunityRecommendationEvent(userId, 'shared_moment_added', { momentId: moment.id, @@ -973,6 +1268,7 @@ export const mockSoundlogService = { } moment.status = input.status; + room.updatedAt = new Date(); recordMockCommunityRecommendationEvent(userId, 'shared_moment_status_updated', { momentId, @@ -1029,6 +1325,9 @@ export const mockSoundlogService = { userId, }; mockDb.travelRoomMomentComments.push(comment); + if (room) { + room.updatedAt = new Date(); + } recordMockCommunityRecommendationEvent(userId, 'shared_moment_commented', { momentId, @@ -1130,11 +1429,15 @@ export const mockSoundlogService = { } const now = new Date(); - const track = findMockTrack(input.trackId) ?? { - id: input.trackId ?? createPublicId('track'), - title: input.trackTitle ?? '선택한 음악', - artist: input.artistName ?? '아티스트 미상', - }; + const track = + findMockTrack(input.trackId) ?? + (input.trackId || input.trackTitle + ? { + id: input.trackId ?? createPublicId('track'), + title: input.trackTitle ?? '선택한 음악', + artist: input.artistName ?? '아티스트 미상', + } + : undefined); const pin = mockDb.soundMapPins.find((item) => item.userId === userId); const nextPin = { id: pin?.id ?? createPublicId('sound_pin'), @@ -1178,13 +1481,27 @@ export const mockSoundlogService = { radiusMeters?: number; visibility?: string; }) { - const blockedIds = mockDb.communityBlocks - .filter((block) => block.blockerId === userId) - .map((block) => block.blockedUserId); + const hiddenUserIds = getMockCommunityHiddenUserIds(userId); + const companionUserIds = getMockCompanionUserIds(userId); + const isVisibleToViewer = (pin: (typeof mockDb.soundMapPins)[number]) => { + if (query.visibility === 'nearby') { + return pin.visibility === 'nearby'; + } + + if (query.visibility === 'companions') { + return pin.userId === userId || ( + pin.visibility === 'companions' && companionUserIds.includes(pin.userId) + ); + } + + return pin.userId === userId || + pin.visibility === 'nearby' || + (pin.visibility === 'companions' && companionUserIds.includes(pin.userId)); + }; const pins = mockDb.soundMapPins .filter((pin) => pin.expiresAt > new Date()) - .filter((pin) => !blockedIds.includes(pin.userId)) - .filter((pin) => (query.visibility ? pin.visibility === query.visibility : pin.visibility !== 'private')); + .filter((pin) => !hiddenUserIds.includes(pin.userId)) + .filter(isVisibleToViewer); const scopedPins = hasGeoPoint(query) ? filterPinsByRadius(pins, query) @@ -1215,13 +1532,11 @@ export const mockSoundlogService = { return []; } - const blockedIds = mockDb.communityBlocks - .filter((block) => block.blockerId === userId) - .map((block) => block.blockedUserId); + const hiddenUserIds = getMockCommunityHiddenUserIds(userId); const pins = mockDb.soundMapPins .filter((pin) => pin.expiresAt > new Date()) .filter((pin) => pin.userId !== userId && pin.visibility === 'nearby') - .filter((pin) => !blockedIds.includes(pin.userId)); + .filter((pin) => !hiddenUserIds.includes(pin.userId)); recordMockCommunityRecommendationEvent(userId, 'nearby_sound_opened', { hasLocation: true, @@ -1283,12 +1598,14 @@ export const mockSoundlogService = { if (targetPin && (targetPin.visibility !== 'nearby' || targetPin.expiresAt <= new Date())) { throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); } + if (hasMockCommunityBlockBetween(userId, targetUserId)) { + throw forbidden(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_BLOCKED); + } const existingActiveRequest = mockDb.travelMateRequests .filter( (request) => request.requesterId === userId && request.targetUserId === targetUserId && - request.targetPinId === input.targetPinId && ['pending', 'accepted'].includes(request.status), ) .sort((first, second) => second.createdAt.getTime() - first.createdAt.getTime())[0]; @@ -1297,6 +1614,23 @@ export const mockSoundlogService = { return mateRequestToDto(existingActiveRequest); } + const recentClosedRequest = mockDb.travelMateRequests + .filter( + (request) => + request.requesterId === userId && + request.targetUserId === targetUserId && + CLOSED_TRAVEL_MATE_REQUEST_STATUSES.includes(request.status) && + request.updatedAt.getTime() >= Date.now() - TRAVEL_MATE_REQUEST_COOLDOWN_MS, + ) + .sort((first, second) => second.updatedAt.getTime() - first.updatedAt.getTime())[0]; + + if (recentClosedRequest) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_RATE_LIMITED, { + cooldownHours: TRAVEL_MATE_REQUEST_COOLDOWN_MS / 60 / 60 / 1000, + requestId: recentClosedRequest.id, + }); + } + const now = new Date(); const request = { id: createPublicId('mate'), @@ -1317,6 +1651,28 @@ export const mockSoundlogService = { return mateRequestToDto(request); }, + async getTravelMateRequests(userId: string, query: { + box?: string; + limit?: number; + status?: string; + }) { + return mockDb.travelMateRequests + .filter((request) => { + const inRequestedBox = + query.box === 'inbox' + ? request.targetUserId === userId + : query.box === 'sent' + ? request.requesterId === userId + : request.requesterId === userId || request.targetUserId === userId; + const inRequestedStatus = query.status ? request.status === query.status : true; + + return inRequestedBox && inRequestedStatus; + }) + .sort((first, second) => second.updatedAt.getTime() - first.updatedAt.getTime()) + .slice(0, getLimit(query.limit)) + .map(mateRequestToDto); + }, + async updateTravelMateRequest(userId: string, requestId: string, input: { action: string }) { const request = mockDb.travelMateRequests.find( (item) => item.id === requestId && (item.requesterId === userId || item.targetUserId === userId), @@ -1376,6 +1732,17 @@ export const mockSoundlogService = { }); } + mockDb.travelMateRequests.forEach((request) => { + const isSamePair = + (request.requesterId === userId && request.targetUserId === targetUserId) || + (request.requesterId === targetUserId && request.targetUserId === userId); + + if (isSamePair && request.status === 'pending') { + request.status = 'cancelled'; + request.updatedAt = new Date(); + } + }); + recordMockCommunityRecommendationEvent(userId, 'community_user_blocked', { targetPinId: input.targetPinId, targetUserId, @@ -1565,6 +1932,17 @@ export const mockSoundlogService = { session.lat = input.location?.lat ?? session.lat; session.lng = input.location?.lng ?? session.lng; + if (session.status === 'ended') { + const now = new Date(); + mockDb.soundMapPins.forEach((pin) => { + if (pin.userId === userId && pin.sessionId === sessionId) { + pin.expiresAt = now; + pin.visibility = 'private'; + pin.updatedAt = now; + } + }); + } + return compact({ id: session.id, status: session.status, diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index ed7fdc0..6970454 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -19,6 +19,8 @@ import { type UserProfile, } from '@prisma/client'; import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { env } from '../config/env.js'; import { ERROR_MESSAGES } from '../constants/error.constants.js'; @@ -78,6 +80,9 @@ type MlRecommendationResponse = { tracks?: unknown; }; +const TRAVEL_MATE_REQUEST_COOLDOWN_MS = 24 * 60 * 60 * 1000; +const CLOSED_TRAVEL_MATE_REQUEST_STATUSES = ['cancelled', 'declined', 'expired']; + type ContextualPlaylistInput = { excludeTrackIds?: string[]; location?: { lat: number; lng: number }; @@ -90,12 +95,35 @@ type ContextualPlaylistInput = { travelMode?: string; }; +type MomentLogUpdateInput = { + artistName?: string; + createdAt?: string; + lat?: number | null; + lng?: number | null; + moodTags?: string[]; + note?: string | null; + placeCategory?: string | null; + placeId?: string | null; + placeName?: string | null; + sessionId?: string | null; + trackId?: string; + trackTitle?: string; + travelMode?: string | null; +}; + function compact>(value: T) { return Object.fromEntries( Object.entries(value).filter(([, item]) => item !== undefined && item !== null), ) as Partial; } +function hasOwn( + value: T, + key: K, +): value is T & Record { + return Object.prototype.hasOwnProperty.call(value, key); +} + function toInputJson(value: unknown): Prisma.InputJsonValue { return JSON.parse(JSON.stringify(value ?? { accepted: true })) as Prisma.InputJsonValue; } @@ -301,6 +329,60 @@ async function recordCommunityRecommendationEvent( }); } +async function touchTravelRoom(roomId: string) { + await prisma.travelRoom.update({ + where: { id: roomId }, + data: { updatedAt: new Date() }, + }); +} + +async function getCompanionUserIds(userId: string) { + const memberships = await prisma.travelRoomMember.findMany({ + where: { + room: { + members: { + some: { userId }, + }, + }, + }, + select: { userId: true }, + }); + + return Array.from(new Set(memberships.map((membership) => membership.userId))); +} + +async function getCommunityHiddenUserIds(userId: string) { + const blocks = await prisma.communityBlock.findMany({ + select: { + blockedUserId: true, + blockerId: true, + }, + where: { + OR: [ + { blockerId: userId }, + { blockedUserId: userId }, + ], + }, + }); + + return Array.from(new Set(blocks.map((block) => + block.blockerId === userId ? block.blockedUserId : block.blockerId, + ))); +} + +async function hasCommunityBlockBetween(userId: string, targetUserId: string) { + const block = await prisma.communityBlock.findFirst({ + where: { + OR: [ + { blockedUserId: targetUserId, blockerId: userId }, + { blockedUserId: userId, blockerId: targetUserId }, + ], + }, + }); + + return Boolean(block); +} + function mateRequestToDto(request: TravelMateRequest) { return { id: request.id, @@ -408,6 +490,33 @@ function normalizePublicUrl(baseUrl: string, path: string) { return `${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`; } +function getLocalUploadedFilePath(photoUrl?: string | null) { + if (!photoUrl) { + return undefined; + } + + const uploadPublicRoot = normalizePublicUrl(env.UPLOAD_PUBLIC_BASE_URL, env.UPLOAD_PUBLIC_PATH); + const fileName = photoUrl.startsWith(`${uploadPublicRoot}/`) + ? photoUrl.slice(uploadPublicRoot.length + 1) + : undefined; + + if (!fileName || fileName.includes('/') || fileName.includes('\\')) { + return undefined; + } + + return path.join(env.UPLOAD_DIRECTORY, fileName); +} + +async function deleteLocalUploadedFile(photoUrl?: string | null) { + const filePath = getLocalUploadedFilePath(photoUrl); + + if (!filePath) { + return; + } + + await fs.unlink(filePath).catch(() => undefined); +} + function asString(value: unknown) { return typeof value === 'string' ? value : undefined; } @@ -773,6 +882,47 @@ type PlaylistWithTracks = Playlist & { tracks: Array; }; +type PlaylistSummarySource = Pick< + Playlist, + | 'backgroundImageUrl' + | 'coverImageUrl' + | 'description' + | 'durationText' + | 'id' + | 'placeName' + | 'reason' + | 'regionName' + | 'trackCount' +>; + +function createFallbackPlaylistSummary(playlistId: string): PlaylistSummarySource { + return { + id: playlistId, + regionName: playlistId, + description: null, + placeName: null, + reason: '', + coverImageUrl: null, + backgroundImageUrl: null, + trackCount: 0, + durationText: '', + }; +} + +function playlistSummaryToDto(playlist: PlaylistSummarySource) { + return compact({ + id: playlist.id, + regionName: playlist.regionName, + placeName: playlist.placeName ?? undefined, + description: playlist.description ?? undefined, + reason: playlist.reason, + coverImageUrl: playlist.coverImageUrl ?? undefined, + backgroundImageUrl: playlist.backgroundImageUrl ?? undefined, + trackCount: playlist.trackCount, + durationText: playlist.durationText, + }); +} + async function getTrackStates(userId: string | undefined, trackIds: string[]) { if (!userId || trackIds.length === 0) { return new Map(); @@ -828,6 +978,24 @@ function profileToDto(profile: UserProfile) { }); } +function withPlaylistContext>( + playlist: T, + context: Record, +) { + const currentContext = + playlist.context && typeof playlist.context === 'object' + ? (playlist.context as Record) + : {}; + + return { + ...playlist, + context: compact({ + ...currentContext, + ...context, + }), + }; +} + function momentLogToDto(log: MomentLog) { return compact({ id: log.id, @@ -840,6 +1008,7 @@ function momentLogToDto(log: MomentLog) { placeCategory: log.placeCategory ?? undefined, placeId: log.placeId ?? undefined, placeName: log.placeName ?? undefined, + note: log.note ?? undefined, track: (log.trackSnapshot as TrackDto | null) ?? undefined, travelMode: log.travelMode ?? undefined, moodTags: log.moodTags, @@ -1185,22 +1354,54 @@ export const soundlogService = { throw notFound(); } - await prisma.playlist.update({ - where: { id: playlist.id }, - data: { - context: compact({ - moodTags: input.moodTags, - placeId: input.placeId, - travelMode: input.travelMode, - }), - }, + return withPlaylistContext(await playlistToDto(playlist, userId), { + mood: input.mood, + moodTags: input.moodTags, + placeId: input.placeId, + source: 'seed-fallback', + state: input.state, + travelMode: input.travelMode, }); - - return playlistToDto(playlist, userId); }, ); }, + async getRecommendedPlaylist( + userId: string | undefined, + input: ContextualPlaylistInput, + ) { + const mlPlaylist = await fetchMlRecommendationPlaylist(input); + + if (mlPlaylist) { + return mlPlaylist; + } + + const playlistId = await findDefaultPlaylist({ + lat: input.location?.lat, + placeId: input.placeId, + }); + const playlist = await prisma.playlist.findUnique({ + where: { id: playlistId }, + include: { + tracks: { + include: { track: true }, + }, + }, + }); + + if (!playlist) { + throw notFound(); + } + + return withPlaylistContext(await playlistToDto(playlist, userId), { + mood: input.mood, + placeId: input.placeId, + source: 'seed-fallback', + state: input.state, + travelMode: input.travelMode, + }); + }, + async getPlaylist( userId: string | undefined, playlistId: string, @@ -1220,7 +1421,11 @@ export const soundlogService = { throw notFound(ERROR_MESSAGES.PLAYLIST_NOT_FOUND); } - return playlistToDto(playlist, userId); + const dto = await playlistToDto(playlist, userId); + + return playlistId === 'fallback' + ? withPlaylistContext(dto, { source: 'seed-fallback' }) + : dto; }, async getLibraryTracks( @@ -1240,10 +1445,22 @@ export const soundlogService = { include: { track: true }, orderBy: { updatedAt: 'desc' }, }); + const playlistIds = Array.from( + new Set(states.map((state) => state.playlistId).filter(Boolean) as string[]), + ); + const playlists = playlistIds.length > 0 + ? await prisma.playlist.findMany({ where: { id: { in: playlistIds } } }) + : []; + const playlistById = new Map(playlists.map((playlist) => [playlist.id, playlist])); const records = states.map((state) => ({ id: state.trackId, createdAt: (state.isLiked ? state.likedAt : state.savedAt)?.toISOString() ?? state.updatedAt.toISOString(), playlistId: state.playlistId ?? undefined, + playlist: state.playlistId + ? playlistSummaryToDto( + playlistById.get(state.playlistId) ?? createFallbackPlaylistSummary(state.playlistId), + ) + : undefined, kind: state.isLiked ? 'liked' : 'saved', track: trackToDto(state.track, state), })); @@ -1341,10 +1558,11 @@ export const soundlogService = { lat?: number; lng?: number; moodTags: string[]; - photoPath: string; + photoPath?: string; placeCategory?: string; placeId?: string; placeName?: string; + note?: string; sessionId?: string; trackId?: string; trackTitle?: string; @@ -1358,7 +1576,9 @@ export const soundlogService = { const track = input.trackId ? await prisma.track.findUnique({ where: { id: input.trackId } }) : undefined; - const photoUrl = normalizePublicUrl(env.UPLOAD_PUBLIC_BASE_URL, input.photoPath); + const photoUrl = input.photoPath + ? normalizePublicUrl(env.UPLOAD_PUBLIC_BASE_URL, input.photoPath) + : undefined; const id = createPublicId('moment'); const log = await prisma.momentLog.create({ data: { @@ -1372,6 +1592,7 @@ export const soundlogService = { placeCategory: input.placeCategory, placeId: input.placeId, placeName: input.placeName, + note: input.note, trackSnapshot: track || input.trackTitle ? { @@ -1394,6 +1615,168 @@ export const soundlogService = { ); }, + async updateMomentLog( + userId: string, + momentLogId: string, + input: MomentLogUpdateInput, + ) { + const existing = await prisma.momentLog.findFirst({ + where: { + id: momentLogId, + userId, + }, + }); + + if (!existing) { + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + + const data: Prisma.MomentLogUpdateInput = {}; + + if (input.createdAt) { + data.createdAt = new Date(input.createdAt); + } + + if (hasOwn(input, 'lat')) { + data.lat = input.lat ?? null; + } + + if (hasOwn(input, 'lng')) { + data.lng = input.lng ?? null; + } + + if (input.moodTags) { + data.moodTags = input.moodTags; + } + + if (hasOwn(input, 'note')) { + data.note = input.note ?? null; + } + + if (hasOwn(input, 'placeCategory')) { + data.placeCategory = input.placeCategory ?? null; + } + + if (hasOwn(input, 'placeId')) { + data.placeId = input.placeId ?? null; + } + + if (hasOwn(input, 'placeName')) { + data.placeName = input.placeName ?? null; + } + + if (hasOwn(input, 'sessionId')) { + data.sessionId = input.sessionId ?? null; + } + + if (hasOwn(input, 'travelMode')) { + data.travelMode = input.travelMode ?? null; + } + + const shouldUpdateTrackSnapshot = + hasOwn(input, 'trackId') || + hasOwn(input, 'trackTitle') || + hasOwn(input, 'artistName'); + + if (shouldUpdateTrackSnapshot) { + const track = input.trackId + ? await prisma.track.findUnique({ where: { id: input.trackId } }) + : undefined; + const trackSnapshot = createTrackSnapshot(track, { + artistName: input.artistName, + trackId: input.trackId, + trackTitle: input.trackTitle, + }); + + data.trackSnapshot = trackSnapshot ? toInputJson(trackSnapshot) : Prisma.JsonNull; + } + + if (Object.keys(data).length === 0) { + return momentLogToDto(existing); + } + + const updated = await prisma.momentLog.update({ + where: { id: existing.id }, + data, + }); + + return momentLogToDto(updated); + }, + + async updateMomentLogPhoto(userId: string, momentLogId: string, photoPath: string) { + const nextPhotoUrl = normalizePublicUrl(env.UPLOAD_PUBLIC_BASE_URL, photoPath); + const existing = await prisma.momentLog.findFirst({ + where: { + id: momentLogId, + userId, + }, + }); + + if (!existing) { + await deleteLocalUploadedFile(nextPhotoUrl); + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + + const updated = await prisma.momentLog.update({ + where: { id: existing.id }, + data: { photoUrl: nextPhotoUrl }, + }); + + await deleteLocalUploadedFile(existing.photoUrl); + + return momentLogToDto(updated); + }, + + async deleteMomentLogPhoto(userId: string, momentLogId: string) { + const existing = await prisma.momentLog.findFirst({ + where: { + id: momentLogId, + userId, + }, + }); + + if (!existing) { + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + + if (!existing.photoUrl) { + return momentLogToDto(existing); + } + + const updated = await prisma.momentLog.update({ + where: { id: existing.id }, + data: { photoUrl: null }, + }); + + await deleteLocalUploadedFile(existing.photoUrl); + + return momentLogToDto(updated); + }, + + async deleteMomentLog(userId: string, momentLogId: string) { + const existing = await prisma.momentLog.findFirst({ + where: { + id: momentLogId, + userId, + }, + select: { id: true, photoUrl: true }, + }); + + if (!existing) { + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + + await prisma.$transaction([ + prisma.travelRoomMoment.updateMany({ + where: { momentLogId: existing.id }, + data: { momentLogId: null }, + }), + prisma.momentLog.delete({ where: { id: existing.id } }), + ]); + + await deleteLocalUploadedFile(existing.photoUrl); + }, + async createRecommendationEvents( userId: string, input: { @@ -1470,6 +1853,29 @@ export const soundlogService = { return roomToDto(room); }, + async getTravelRooms(userId: string, query: { + limit?: number; + sessionId?: string; + }) { + const rooms = await prisma.travelRoom.findMany({ + where: { + ...(query.sessionId ? { sessionId: query.sessionId } : {}), + members: { some: { userId } }, + }, + include: { + members: true, + moments: { + include: { comments: { orderBy: { createdAt: 'asc' } } }, + orderBy: { createdAt: 'desc' }, + }, + }, + orderBy: { updatedAt: 'desc' }, + take: getLimit(query.limit), + }); + + return rooms.map(roomToDto); + }, + async getTravelRoom(userId: string, roomId: string) { const room = await prisma.travelRoom.findFirst({ where: { @@ -1538,6 +1944,7 @@ export const soundlogService = { role: 'member', }, }); + await touchTravelRoom(roomId); await recordCommunityRecommendationEvent(userId, 'trip_room_joined', { roomId, @@ -1547,6 +1954,21 @@ export const soundlogService = { return this.getTravelRoom(userId, roomId); }, + async joinTravelRoomByInviteCode(userId: string, input: { + displayName?: string; + inviteCode: string; + }) { + const room = await prisma.travelRoom.findUnique({ + where: { inviteCode: input.inviteCode }, + }); + + if (!room) { + throw badRequest(ERROR_MESSAGES.TRAVEL_ROOM_INVITE_CODE_INVALID); + } + + return this.joinTravelRoom(userId, room.id, input); + }, + async addTravelRoomMoment(userId: string, roomId: string, input: { artistName?: string; momentLogId?: string; @@ -1598,6 +2020,7 @@ export const soundlogService = { trackSnapshot: trackSnapshot ? toInputJson(trackSnapshot) : undefined, }, }); + await touchTravelRoom(roomId); await recordCommunityRecommendationEvent(userId, 'shared_moment_added', { roomId, @@ -1647,6 +2070,7 @@ export const soundlogService = { data: { status: input.status }, include: { comments: { orderBy: { createdAt: 'asc' } } }, }); + await touchTravelRoom(roomId); await recordCommunityRecommendationEvent(userId, 'shared_moment_status_updated', { roomId, @@ -1705,6 +2129,7 @@ export const soundlogService = { userId, }, }); + await touchTravelRoom(roomId); await recordCommunityRecommendationEvent(userId, 'shared_moment_commented', { roomId, @@ -1835,6 +2260,7 @@ export const soundlogService = { trackId: input.trackId, trackTitle: input.trackTitle, }); + const trackSnapshotValue = trackSnapshot ? toInputJson(trackSnapshot) : Prisma.JsonNull; const expiresAt = new Date(Date.now() + (input.ttlMinutes ?? 120) * 60_000); const pin = await prisma.soundMapPin.upsert({ where: { userId }, @@ -1847,7 +2273,7 @@ export const soundlogService = { moodTags: input.moodTags ?? [], placeName: input.placeName, sessionId: session.id, - trackSnapshot: trackSnapshot ? toInputJson(trackSnapshot) : undefined, + trackSnapshot: trackSnapshotValue, travelMode: input.travelMode ?? session.travelMode, visibility: input.visibility, }, @@ -1861,7 +2287,7 @@ export const soundlogService = { moodTags: input.moodTags ?? [], placeName: input.placeName, sessionId: session.id, - trackSnapshot: trackSnapshot ? toInputJson(trackSnapshot) : undefined, + trackSnapshot: trackSnapshotValue, travelMode: input.travelMode ?? session.travelMode, userId, visibility: input.visibility, @@ -1900,18 +2326,39 @@ export const soundlogService = { radiusMeters?: number; visibility?: string; }) { - const blockedUsers = await prisma.communityBlock.findMany({ - select: { blockedUserId: true }, - where: { blockerId: userId }, - }); + const [hiddenUserIds, companionUserIds] = await Promise.all([ + getCommunityHiddenUserIds(userId), + getCompanionUserIds(userId), + ]); + const visibilityScope = + query.visibility === 'nearby' + ? { visibility: 'nearby' } + : query.visibility === 'companions' + ? { + OR: [ + { userId }, + { + userId: { in: companionUserIds }, + visibility: 'companions', + }, + ], + } + : { + OR: [ + { userId }, + { visibility: 'nearby' }, + { + userId: { in: companionUserIds }, + visibility: 'companions', + }, + ], + }; const pins = await prisma.soundMapPin.findMany({ where: { AND: [ { expiresAt: { gt: new Date() } }, - { userId: { notIn: blockedUsers.map((block) => block.blockedUserId) } }, - query.visibility - ? { visibility: query.visibility } - : { OR: [{ userId }, { visibility: { not: 'private' } }] }, + { userId: { notIn: hiddenUserIds } }, + visibilityScope, ], }, include: { @@ -1960,15 +2407,12 @@ export const soundlogService = { return []; } - const blockedUsers = await prisma.communityBlock.findMany({ - select: { blockedUserId: true }, - where: { blockerId: userId }, - }); + const hiddenUserIds = await getCommunityHiddenUserIds(userId); const pins = await prisma.soundMapPin.findMany({ where: { AND: [ { userId: { not: userId } }, - { userId: { notIn: blockedUsers.map((block) => block.blockedUserId) } }, + { userId: { notIn: hiddenUserIds } }, ], expiresAt: { gt: new Date() }, visibility: 'nearby', @@ -2058,11 +2502,14 @@ export const soundlogService = { throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); } + if (await hasCommunityBlockBetween(userId, targetUserId)) { + throw forbidden(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_BLOCKED); + } + const existingActiveRequest = await prisma.travelMateRequest.findFirst({ where: { requesterId: userId, status: { in: ['pending', 'accepted'] }, - targetPinId: input.targetPinId, targetUserId, }, orderBy: { createdAt: 'desc' }, @@ -2072,6 +2519,25 @@ export const soundlogService = { return mateRequestToDto(existingActiveRequest); } + const recentClosedRequest = await prisma.travelMateRequest.findFirst({ + where: { + requesterId: userId, + status: { in: CLOSED_TRAVEL_MATE_REQUEST_STATUSES }, + targetUserId, + updatedAt: { + gte: new Date(Date.now() - TRAVEL_MATE_REQUEST_COOLDOWN_MS), + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + + if (recentClosedRequest) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_RATE_LIMITED, { + cooldownHours: TRAVEL_MATE_REQUEST_COOLDOWN_MS / 60 / 60 / 1000, + requestId: recentClosedRequest.id, + }); + } + const request = await prisma.travelMateRequest.create({ data: { id: createPublicId('mate'), @@ -2092,6 +2558,35 @@ export const soundlogService = { return mateRequestToDto(request); }, + async getTravelMateRequests(userId: string, query: { + box?: string; + limit?: number; + status?: string; + }) { + const ownershipWhere = + query.box === 'inbox' + ? { targetUserId: userId } + : query.box === 'sent' + ? { requesterId: userId } + : { + OR: [ + { requesterId: userId }, + { targetUserId: userId }, + ], + }; + + const requests = await prisma.travelMateRequest.findMany({ + where: { + ...ownershipWhere, + ...(query.status ? { status: query.status } : {}), + }, + orderBy: { updatedAt: 'desc' }, + take: getLimit(query.limit), + }); + + return requests.map(mateRequestToDto); + }, + async updateTravelMateRequest(userId: string, requestId: string, input: { action: string }) { const request = await prisma.travelMateRequest.findUnique({ where: { id: requestId } }); @@ -2160,6 +2655,17 @@ export const soundlogService = { }, }); + await prisma.travelMateRequest.updateMany({ + where: { + OR: [ + { requesterId: userId, targetUserId }, + { requesterId: targetUserId, targetUserId: userId }, + ], + status: 'pending', + }, + data: { status: 'cancelled' }, + }); + await recordCommunityRecommendationEvent(userId, 'community_user_blocked', { targetPinId: input.targetPinId, targetUserId, @@ -2406,6 +2912,19 @@ export const soundlogService = { }, }); + if (updated.status === 'ended') { + await prisma.soundMapPin.updateMany({ + where: { + sessionId, + userId, + }, + data: { + expiresAt: new Date(), + visibility: 'private', + }, + }); + } + return travelSessionToDto(updated); }, diff --git a/src/validators/api.validators.ts b/src/validators/api.validators.ts index 68727fd..3ed6d06 100644 --- a/src/validators/api.validators.ts +++ b/src/validators/api.validators.ts @@ -52,6 +52,12 @@ const queryBoolean = z.preprocess((value) => { const limit = z.coerce.number().int().min(1).max(50).optional(); const cursor = z.string().optional(); +const inviteCodeSchema = z + .string() + .trim() + .min(4) + .max(16) + .transform((code) => code.toUpperCase()); export const travelModeSchema = z.enum([ 'walk', @@ -200,6 +206,12 @@ export const playlistValidators = { state: mlTravelStateSchema.optional(), travelMode: travelModeSchema.optional(), }), + recommendationQuery: z.object({ + mood: mlMoodSchema, + state: mlTravelStateSchema, + x: queryNumber.min(-180).max(180), + y: queryNumber.min(-90).max(90), + }), }; export const libraryValidators = { @@ -224,12 +236,16 @@ export const momentLogValidators = { limit, sessionId: z.string().optional(), }), + momentLogParams: z.object({ + momentLogId: z.string().min(1), + }), createBody: z.object({ artistName: z.string().optional(), createdAt: z.string().datetime(), lat: z.coerce.number().optional(), lng: z.coerce.number().optional(), moodTags: requiredStringArray.pipe(z.array(moodTagSchema)), + note: z.string().trim().max(240).optional(), placeCategory: z.string().optional(), placeId: z.string().optional(), placeName: z.string().optional(), @@ -238,6 +254,21 @@ export const momentLogValidators = { trackTitle: z.string().optional(), travelMode: travelModeSchema.optional(), }), + updateBody: z.object({ + artistName: z.string().optional(), + createdAt: z.string().datetime().optional(), + lat: z.union([z.coerce.number(), z.null()]).optional(), + lng: z.union([z.coerce.number(), z.null()]).optional(), + moodTags: requiredStringArray.pipe(z.array(moodTagSchema)).optional(), + note: z.union([z.string().trim().max(240), z.null()]).optional(), + placeCategory: z.union([z.string(), z.null()]).optional(), + placeId: z.union([z.string(), z.null()]).optional(), + placeName: z.union([z.string(), z.null()]).optional(), + sessionId: z.union([z.string(), z.null()]).optional(), + trackId: z.string().optional(), + trackTitle: z.string().optional(), + travelMode: z.union([travelModeSchema, z.null()]).optional(), + }), }; export const recommendationEventValidators = { @@ -253,11 +284,16 @@ export const recommendationEventValidators = { trackId: z.string().optional(), type: z.enum([ 'track_external_open', + 'external_music_open_failed', + 'track_selected', 'track_like', 'track_unlike', 'track_save', 'track_unsave', + 'moment_log_saved', + 'moment_log_sync_failed', 'playlist_open', + 'mood_adjusted', 'mood_filter_change', 'live_track_shared', 'nearby_sound_opened', @@ -343,9 +379,17 @@ export const communityValidators = { title: z.string().trim().min(1).max(80).default('Soundlog 여행방'), visibility: z.enum(['invite_only', 'companions']).optional().default('invite_only'), }), + listRoomsQuery: z.object({ + limit, + sessionId: z.string().optional(), + }), joinRoomBody: z.object({ displayName: z.string().trim().min(1).max(40).optional(), - inviteCode: z.string().trim().min(4).max(16).optional(), + inviteCode: inviteCodeSchema.optional(), + }), + joinRoomByInviteBody: z.object({ + displayName: z.string().trim().min(1).max(40).optional(), + inviteCode: inviteCodeSchema, }), addRoomMomentBody: z.object({ artistName: z.string().trim().max(120).optional(), @@ -399,6 +443,13 @@ export const communityValidators = { targetPinId: z.string().optional(), targetUserId: z.string().optional(), }), + listMateRequestsQuery: z.object({ + box: z.enum(['all', 'inbox', 'sent']).optional().default('all'), + limit, + status: z + .enum(['accepted', 'cancelled', 'declined', 'expired', 'pending']) + .optional(), + }), mateRequestParams: z.object({ requestId: z.string().min(1), }), diff --git a/tests/api.test.ts b/tests/api.test.ts index 0615862..3338b9f 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -74,6 +74,7 @@ function findSecretLeaks(value: unknown, secret: string, path = '$'): string[] { async function createTestMomentLog(input: { authHeader: string; filename: string; + note?: string; placeName: string; sessionId?: string; trackId?: string; @@ -83,6 +84,7 @@ async function createTestMomentLog(input: { .set('Authorization', input.authHeader) .field('createdAt', new Date().toISOString()) .field('moodTags', 'fresh,calm') + .field('note', input.note ?? '') .field('placeName', input.placeName) .field('sessionId', input.sessionId ?? '') .field('trackId', input.trackId ?? 'seoul-city') @@ -162,9 +164,19 @@ describe('Soundlog API', () => { it('rejects protected endpoints without bearer token', async () => { const response = await request(app).get('/v1/me/profile'); + const home = await request(app) + .get('/v1/home/featured-playlists') + .query({ + locationRecommendationEnabled: true, + recommendationMode: 'travel', + lat: 35.1532, + lng: 129.1186, + }); expect(response.status).toBe(401); expect(response.body.error.code).toBe('UNAUTHORIZED'); + expect(home.status).toBe(401); + expect(home.body.error.code).toBe('UNAUTHORIZED'); }); it('returns JSON not found for unknown routes', async () => { @@ -282,11 +294,14 @@ describe('Soundlog API', () => { }); it('returns tour and home data', async () => { - const tour = await request(app).get('/v1/tour/nearby-places').query({ - lat: 35.1532, - lng: 129.1186, - limit: 2, - }); + const tour = await request(app) + .get('/v1/tour/nearby-places') + .set('Authorization', authHeader) + .query({ + lat: 35.1532, + lng: 129.1186, + limit: 2, + }); expect(tour.status).toBe(200); expect(tour.body.data[0].id).toContain('seed-'); expect(tour.body.data[0].source).toBe('seed'); @@ -298,11 +313,11 @@ describe('Soundlog API', () => { expect(featured.status).toBe(200); expect(featured.body.data.length).toBeGreaterThan(0); - const publicFeatured = await request(app) + const unauthenticatedFeatured = await request(app) .get('/v1/home/featured-playlists') .query({ locationRecommendationEnabled: true, recommendationMode: 'travel', lat: 35.1532, lng: 129.1186 }); - expect(publicFeatured.status).toBe(200); - expect(publicFeatured.body.data[0].id).toBe('busan-ocean'); + expect(unauthenticatedFeatured.status).toBe(401); + expect(unauthenticatedFeatured.body.error.code).toBe('UNAUTHORIZED'); const mood = await request(app) .get('/v1/home/mood-recommendations') @@ -339,6 +354,50 @@ describe('Soundlog API', () => { }); expect(contextual.status).toBe(201); expect(contextual.body.data.tracks.length).toBeGreaterThan(0); + expect(['ml-recommendation', 'seed-fallback']).toContain( + contextual.body.data.context.source, + ); + + const noLocationContextual = await request(app) + .post('/v1/playlists/contextual') + .set('Authorization', authHeader) + .send({ + mood: '잔잔한', + state: '산책', + travelMode: 'walk', + }); + expect(noLocationContextual.status).toBe(201); + expect(noLocationContextual.body.data.context).toMatchObject({ + source: 'seed-fallback', + state: '산책', + travelMode: 'walk', + }); + + const recommended = await request(app) + .get('/v1/recommendations/playlists') + .set('Authorization', authHeader) + .query({ + mood: '시원한', + state: '바다', + x: 129.1186, + y: 35.1532, + }); + expect(recommended.status).toBe(200); + expect(recommended.body.data.tracks.length).toBeGreaterThan(0); + expect(['ml-recommendation', 'seed-fallback']).toContain( + recommended.body.data.context.source, + ); + + const invalidRecommendation = await request(app) + .get('/v1/recommendations/playlists') + .set('Authorization', authHeader) + .query({ + mood: '편안한', + state: '바다', + x: 129.1186, + y: 35.1532, + }); + expect(invalidRecommendation.status).toBe(400); const detail = await request(app) .get('/v1/playlists/busan-ocean') @@ -346,9 +405,9 @@ describe('Soundlog API', () => { expect(detail.status).toBe(200); expect(detail.body.data.id).toBe('busan-ocean'); - const publicDetail = await request(app).get('/v1/playlists/busan-ocean'); - expect(publicDetail.status).toBe(200); - expect(publicDetail.body.data.id).toBe('busan-ocean'); + const unauthenticatedDetail = await request(app).get('/v1/playlists/busan-ocean'); + expect(unauthenticatedDetail.status).toBe(401); + expect(unauthenticatedDetail.body.error.code).toBe('UNAUTHORIZED'); }); it('handles library APIs', async () => { @@ -365,7 +424,17 @@ describe('Soundlog API', () => { .query({ kind: 'liked' }); expect(list.status).toBe(200); expect(list.body.page.limit).toBeGreaterThan(0); - expect(list.body.data.some((item: { track: { id: string } }) => item.track.id === 'moon-seoul')).toBe(true); + const moonRecord = list.body.data.find( + (item: { track: { id: string } }) => item.track.id === 'moon-seoul', + ); + + expect(moonRecord).toBeTruthy(); + expect(moonRecord.playlist).toMatchObject({ + id: 'busan-ocean', + placeName: '광안리 해변', + regionName: '부산', + trackCount: 5, + }); }); it('handles moment log APIs', async () => { @@ -376,6 +445,7 @@ describe('Soundlog API', () => { .set('Idempotency-Key', idempotencyKey) .field('createdAt', new Date().toISOString()) .field('moodTags', 'fresh,calm') + .field('note', '카페 거리에서 남긴 테스트 메모') .field('placeName', '테스트 장소') .field('trackId', 'seoul-city') .attach('photo', Buffer.from('fake-image'), { @@ -385,6 +455,7 @@ describe('Soundlog API', () => { expect(created.status).toBe(201); expect(created.body.data.photoUrl).toContain('/uploads/'); + expect(created.body.data.note).toBe('카페 거리에서 남긴 테스트 메모'); const duplicate = await request(app) .post('/v1/moment-logs') @@ -392,6 +463,7 @@ describe('Soundlog API', () => { .set('Idempotency-Key', idempotencyKey) .field('createdAt', new Date().toISOString()) .field('moodTags', 'fresh') + .field('note', '중복 요청 메모는 반영되지 않아야 함') .field('placeName', '중복 요청 장소') .field('trackId', 'seoul-city') .attach('photo', Buffer.from('fake-image'), { @@ -400,12 +472,112 @@ describe('Soundlog API', () => { }); expect(duplicate.status).toBe(201); expect(duplicate.body.data.id).toBe(created.body.data.id); + expect(duplicate.body.data.note).toBe('카페 거리에서 남긴 테스트 메모'); + + const textOnlyMoment = await request(app) + .post('/v1/moment-logs') + .set('Authorization', authHeader) + .field('createdAt', new Date().toISOString()) + .field('moodTags', 'calm') + .field('note', '사진 없이 남긴 테스트 메모') + .field('placeName', '텍스트 기록 장소') + .field('trackTitle', '사진 없는 순간'); + expect(textOnlyMoment.status).toBe(201); + expect(textOnlyMoment.body.data.photoUrl).toBeUndefined(); + expect(textOnlyMoment.body.data.note).toBe('사진 없이 남긴 테스트 메모'); + + const minimalMoment = await request(app) + .post('/v1/moment-logs') + .set('Authorization', authHeader) + .field('createdAt', new Date().toISOString()) + .field('moodTags', ''); + expect(minimalMoment.status).toBe(201); + expect(minimalMoment.body.data.photoUrl).toBeUndefined(); + expect(minimalMoment.body.data.location).toBeUndefined(); + expect(minimalMoment.body.data.placeName).toBeUndefined(); + expect(minimalMoment.body.data.track).toBeUndefined(); + expect(minimalMoment.body.data.moodTags).toEqual([]); + expect(minimalMoment.body.data.syncStatus).toBe('synced'); + + const updated = await request(app) + .patch(`/v1/moment-logs/${created.body.data.id}`) + .set('Authorization', authHeader) + .send({ + moodTags: ['calm'], + note: '수정된 테스트 메모', + placeName: '수정된 테스트 장소', + }); + expect(updated.status).toBe(200); + expect(updated.body.data.id).toBe(created.body.data.id); + expect(updated.body.data.moodTags).toEqual(['calm']); + expect(updated.body.data.note).toBe('수정된 테스트 메모'); + expect(updated.body.data.placeName).toBe('수정된 테스트 장소'); + + const trackUpdated = await request(app) + .patch(`/v1/moment-logs/${created.body.data.id}`) + .set('Authorization', authHeader) + .send({ + artistName: '수정된 아티스트', + trackTitle: '수정된 연결 곡', + }); + expect(trackUpdated.status).toBe(200); + expect(trackUpdated.body.data.track.title).toBe('수정된 연결 곡'); + expect(trackUpdated.body.data.track.artist).toBe('수정된 아티스트'); + + const photoUpdated = await request(app) + .put(`/v1/moment-logs/${created.body.data.id}/photo`) + .set('Authorization', authHeader) + .attach('photo', Buffer.from('replacement-image'), { + filename: 'moment-replacement.jpg', + contentType: 'image/jpeg', + }); + expect(photoUpdated.status).toBe(200); + expect(photoUpdated.body.data.photoUrl).toContain('/uploads/'); + expect(photoUpdated.body.data.photoUrl).not.toBe(created.body.data.photoUrl); + + const missingPhoto = await request(app) + .put(`/v1/moment-logs/${created.body.data.id}/photo`) + .set('Authorization', authHeader); + expect(missingPhoto.status).toBe(400); + expect(missingPhoto.body.error.code).toBe('BAD_REQUEST'); + + const photoDeleted = await request(app) + .delete(`/v1/moment-logs/${created.body.data.id}/photo`) + .set('Authorization', authHeader); + expect(photoDeleted.status).toBe(200); + expect(photoDeleted.body.data.photoUrl).toBeUndefined(); + + const clearedNote = await request(app) + .patch(`/v1/moment-logs/${created.body.data.id}`) + .set('Authorization', authHeader) + .send({ note: null }); + expect(clearedNote.status).toBe(200); + expect(clearedNote.body.data.note).toBeUndefined(); + + const missingUpdate = await request(app) + .patch('/v1/moment-logs/missing-moment-log') + .set('Authorization', authHeader) + .send({ note: '없는 로그 수정' }); + expect(missingUpdate.status).toBe(404); + + const deleted = await request(app) + .delete(`/v1/moment-logs/${textOnlyMoment.body.data.id}`) + .set('Authorization', authHeader); + expect(deleted.status).toBe(202); + expect(deleted.body.data.accepted).toBe(true); + + const missingDelete = await request(app) + .delete(`/v1/moment-logs/${textOnlyMoment.body.data.id}`) + .set('Authorization', authHeader); + expect(missingDelete.status).toBe(404); const list = await request(app) .get('/v1/moment-logs') .set('Authorization', authHeader); expect(list.status).toBe(200); expect(list.body.data.length).toBeGreaterThan(0); + expect(list.body.data.some((item: { id: string }) => item.id === textOnlyMoment.body.data.id)).toBe(false); + expect(list.body.data.some((item: { placeName?: string }) => item.placeName === '수정된 테스트 장소')).toBe(true); }); it('accepts recommendation events', async () => { @@ -434,6 +606,46 @@ describe('Soundlog API', () => { createdAt: new Date().toISOString(), value: 'nearby', }, + { + id: `event-external-failed-${Date.now()}`, + sessionId: 'seed-session', + type: 'external_music_open_failed', + trackId: 'seoul-city', + playlistId: 'seoul-night', + context: { moodFilter: '잔잔한', placeName: '서울 야경 산책' }, + createdAt: new Date().toISOString(), + value: 'external_link', + }, + { + id: `event-track-selected-${Date.now()}`, + sessionId: 'seed-session', + type: 'track_selected', + trackId: 'seoul-city', + playlistId: 'seoul-night', + context: { moodFilter: '잔잔한', placeName: '서울 야경 산책' }, + createdAt: new Date().toISOString(), + value: 'playlist_detail', + }, + { + id: `event-moment-saved-${Date.now()}`, + sessionId: 'seed-session', + type: 'moment_log_saved', + trackId: 'seoul-city', + playlistId: 'seoul-night', + context: { moodFilter: '잔잔한', placeName: '서울 야경 산책' }, + createdAt: new Date().toISOString(), + value: 'pending', + }, + { + id: `event-moment-sync-failed-${Date.now()}`, + sessionId: 'seed-session', + type: 'moment_log_sync_failed', + trackId: 'seoul-city', + playlistId: 'seoul-night', + context: { moodFilter: '잔잔한', placeName: '서울 야경 산책' }, + createdAt: new Date().toISOString(), + value: 'network_error', + }, ], }); @@ -532,6 +744,21 @@ describe('Soundlog API', () => { expect(targetLogin.status).toBe(200); const targetAuthHeader = `Bearer ${targetLogin.body.data.accessToken}`; + const outsiderEmail = `outsider-${Date.now()}@soundlog.test`; + const outsiderPassword = 'soundlog-password'; + const outsiderRegister = await request(app).post('/v1/auth/register').send({ + displayName: 'Unrelated Traveler', + email: outsiderEmail, + password: outsiderPassword, + }); + expect(outsiderRegister.status).toBe(201); + const outsiderLogin = await request(app).post('/v1/auth/login').send({ + email: outsiderEmail, + password: outsiderPassword, + }); + expect(outsiderLogin.status).toBe(200); + const outsiderAuthHeader = `Bearer ${outsiderLogin.body.data.accessToken}`; + const ownerSession = await request(app) .post('/v1/travel-sessions') .set('Authorization', authHeader) @@ -560,6 +787,15 @@ describe('Soundlog API', () => { expect(room.status).toBe(201); expect(room.body.data.inviteCode).toEqual(expect.any(String)); + const ownerRooms = await request(app) + .get('/v1/travel-rooms') + .set('Authorization', authHeader) + .query({ sessionId: ownerSession.body.data.id }); + expect(ownerRooms.status).toBe(200); + expect(ownerRooms.body.data.map((item: { id: string }) => item.id)).toContain( + room.body.data.id, + ); + const missingInvite = await request(app) .post(`/v1/travel-rooms/${room.body.data.id}/join`) .set('Authorization', targetAuthHeader) @@ -574,10 +810,29 @@ describe('Soundlog API', () => { .send({ displayName: '수경', inviteCode: room.body.data.inviteCode, - }); + }); expect(joined.status).toBe(200); expect(joined.body.data.memberCount).toBe(2); + const targetRooms = await request(app) + .get('/v1/travel-rooms') + .set('Authorization', targetAuthHeader) + .query({ sessionId: ownerSession.body.data.id }); + expect(targetRooms.status).toBe(200); + expect(targetRooms.body.data.map((item: { id: string }) => item.id)).toContain( + room.body.data.id, + ); + + const joinedByInviteCode = await request(app) + .post('/v1/travel-rooms/join') + .set('Authorization', targetAuthHeader) + .send({ + displayName: '수경', + inviteCode: room.body.data.inviteCode.toLowerCase(), + }); + expect(joinedByInviteCode.status).toBe(200); + expect(joinedByInviteCode.body.data.id).toBe(room.body.data.id); + const invalidMomentLog = await request(app) .post(`/v1/travel-rooms/${room.body.data.id}/moments`) .set('Authorization', targetAuthHeader) @@ -649,7 +904,7 @@ describe('Soundlog API', () => { trackId: 'seoul-city', travelMode: 'walk', visibility: 'companions', - }); + }); expect(myPin.status).toBe(202); const map = await request(app) @@ -659,6 +914,42 @@ describe('Soundlog API', () => { expect(map.status).toBe(200); expect(map.body.data.length).toBeGreaterThanOrEqual(2); + const targetCompanionMap = await request(app) + .get('/v1/sound-map') + .set('Authorization', targetAuthHeader) + .query({ lat: 37.752, lng: 128.876, radiusMeters: 3000 }); + expect(targetCompanionMap.status).toBe(200); + expect(targetCompanionMap.body.data.map((pin: { id: string }) => pin.id)).toContain( + myPin.body.data.id, + ); + + const outsiderMap = await request(app) + .get('/v1/sound-map') + .set('Authorization', outsiderAuthHeader) + .query({ lat: 37.752, lng: 128.876, radiusMeters: 3000 }); + expect(outsiderMap.status).toBe(200); + expect(outsiderMap.body.data.map((pin: { id: string }) => pin.id)).not.toContain( + myPin.body.data.id, + ); + expect(outsiderMap.body.data.map((pin: { id: string }) => pin.id)).toContain( + targetPin.body.data.id, + ); + + const endedOwnerSession = await request(app) + .patch(`/v1/travel-sessions/${ownerSession.body.data.id}`) + .set('Authorization', authHeader) + .send({ status: 'ended', endedAt: new Date().toISOString() }); + expect(endedOwnerSession.status).toBe(200); + + const mapAfterEndingOwnerSession = await request(app) + .get('/v1/sound-map') + .set('Authorization', targetAuthHeader) + .query({ lat: 37.752, lng: 128.876, radiusMeters: 3000 }); + expect(mapAfterEndingOwnerSession.status).toBe(200); + expect( + mapAfterEndingOwnerSession.body.data.map((pin: { id: string }) => pin.id), + ).not.toContain(myPin.body.data.id); + const smallRadiusMap = await request(app) .get('/v1/sound-map') .set('Authorization', authHeader) @@ -708,6 +999,24 @@ describe('Soundlog API', () => { expect(mateRequest.status).toBe(201); expect(mateRequest.body.data.status).toBe('pending'); + const targetInbox = await request(app) + .get('/v1/travel-mate-requests') + .set('Authorization', targetAuthHeader) + .query({ box: 'inbox', status: 'pending' }); + expect(targetInbox.status).toBe(200); + expect(targetInbox.body.data.map((item: { id: string }) => item.id)).toContain( + mateRequest.body.data.id, + ); + + const requesterSent = await request(app) + .get('/v1/travel-mate-requests') + .set('Authorization', authHeader) + .query({ box: 'sent', status: 'pending' }); + expect(requesterSent.status).toBe(200); + expect(requesterSent.body.data.map((item: { id: string }) => item.id)).toContain( + mateRequest.body.data.id, + ); + const requesterAccept = await request(app) .patch(`/v1/travel-mate-requests/${mateRequest.body.data.id}`) .set('Authorization', authHeader) @@ -724,6 +1033,32 @@ describe('Soundlog API', () => { expect(duplicateMateRequest.status).toBe(201); expect(duplicateMateRequest.body.data.id).toBe(mateRequest.body.data.id); + const outsiderMateRequest = await request(app) + .post('/v1/travel-mate-requests') + .set('Authorization', outsiderAuthHeader) + .send({ + messageTemplate: 'walk_together', + targetPinId: targetPin.body.data.id, + }); + expect(outsiderMateRequest.status).toBe(201); + + const declinedOutsiderRequest = await request(app) + .patch(`/v1/travel-mate-requests/${outsiderMateRequest.body.data.id}`) + .set('Authorization', targetAuthHeader) + .send({ action: 'decline' }); + expect(declinedOutsiderRequest.status).toBe(200); + expect(declinedOutsiderRequest.body.data.status).toBe('declined'); + + const repeatedOutsiderMateRequest = await request(app) + .post('/v1/travel-mate-requests') + .set('Authorization', outsiderAuthHeader) + .send({ + messageTemplate: 'walk_together', + targetPinId: targetPin.body.data.id, + }); + expect(repeatedOutsiderMateRequest.status).toBe(400); + expect(repeatedOutsiderMateRequest.body.error.message).toContain('최근 처리된 동행 매칭 요청'); + const accepted = await request(app) .patch(`/v1/travel-mate-requests/${mateRequest.body.data.id}`) .set('Authorization', targetAuthHeader) @@ -731,6 +1066,15 @@ describe('Soundlog API', () => { expect(accepted.status).toBe(200); expect(accepted.body.data.status).toBe('accepted'); + const acceptedInbox = await request(app) + .get('/v1/travel-mate-requests') + .set('Authorization', targetAuthHeader) + .query({ box: 'inbox', status: 'accepted' }); + expect(acceptedInbox.status).toBe(200); + expect(acceptedInbox.body.data.map((item: { id: string }) => item.id)).toContain( + mateRequest.body.data.id, + ); + const report = await request(app) .post('/v1/community/reports') .set('Authorization', authHeader) @@ -747,6 +1091,16 @@ describe('Soundlog API', () => { .send({ targetPinId: targetPin.body.data.id }); expect(block.status).toBe(202); + const blockedMateRequest = await request(app) + .post('/v1/travel-mate-requests') + .set('Authorization', authHeader) + .send({ + messageTemplate: 'liked_track', + targetPinId: targetPin.body.data.id, + }); + expect(blockedMateRequest.status).toBe(403); + expect(blockedMateRequest.body.error.message).toContain('차단 관계'); + const hiddenMatches = await request(app) .get('/v1/music-matches') .set('Authorization', authHeader) @@ -755,9 +1109,10 @@ describe('Soundlog API', () => { expect(hiddenMatches.body.data).toEqual([]); }); - it('returns regional trends without auth', async () => { + it('returns regional trends with auth', async () => { const response = await request(app) .get('/v1/trends/regions/KR-26/sound') + .set('Authorization', authHeader) .query({ period: 'weekly' }); expect(response.status).toBe(200); diff --git a/tests/middleware-utils.test.ts b/tests/middleware-utils.test.ts new file mode 100644 index 0000000..62e8200 --- /dev/null +++ b/tests/middleware-utils.test.ts @@ -0,0 +1,141 @@ +import { Prisma } from '@prisma/client'; +import express from 'express'; +import multer from 'multer'; +import request from 'supertest'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { ERROR_CODES, ERROR_MESSAGES } from '../src/constants/error.constants.js'; +import { errorMiddleware } from '../src/middlewares/error.middleware.js'; +import { requireUser } from '../src/middlewares/auth.middleware.js'; +import { createUploadedFilePublicPath } from '../src/middlewares/upload.middleware.js'; +import { validate } from '../src/middlewares/validate.middleware.js'; +import { badRequest, notFound } from '../src/utils/http-error.js'; +import { acceptedResponse, pagedResponse } from '../src/utils/response.js'; + +function createErrorTestApp() { + const app = express(); + + app.use(express.json()); + app.get('/http-error', () => { + throw notFound('missing route'); + }); + app.get('/zod-error', () => { + z.object({ id: z.string().uuid() }).parse({ id: 'not-a-uuid' }); + }); + app.get('/multer-error', (_req, _res, next) => { + next(new multer.MulterError('LIMIT_FILE_SIZE', 'photo')); + }); + app.get('/prisma-error', (_req, _res, next) => { + next( + new Prisma.PrismaClientKnownRequestError('unique failed', { + clientVersion: 'test', + code: 'P2002', + }), + ); + }); + app.get('/unknown-error', () => { + throw new Error('boom'); + }); + app.get( + '/validated', + validate({ + query: z.object({ + limit: z.coerce.number().int().min(1), + }), + }), + (req, res) => { + res.json({ limitType: typeof req.query.limit, value: req.query.limit }); + }, + ); + app.get( + '/invalid', + validate({ + query: z.object({ + limit: z.coerce.number().int().min(1), + }), + }), + (_req, res) => { + res.json({ ok: true }); + }, + ); + app.get('/bad-request', () => { + throw badRequest('bad input', { reason: 'test' }); + }); + app.use(errorMiddleware); + + return app; +} + +describe('middleware and response utilities', () => { + it('serializes known application, validation, upload, and database errors', async () => { + const app = createErrorTestApp(); + + const httpError = await request(app).get('/http-error'); + const zodError = await request(app).get('/zod-error'); + const uploadError = await request(app).get('/multer-error'); + const prismaError = await request(app).get('/prisma-error'); + const badInput = await request(app).get('/bad-request'); + + expect(httpError.status).toBe(404); + expect(httpError.body.error).toMatchObject({ + code: ERROR_CODES.NOT_FOUND, + message: 'missing route', + }); + expect(zodError.status).toBe(400); + expect(zodError.body.error.details.issues).toHaveLength(1); + expect(uploadError.status).toBe(400); + expect(uploadError.body.error).toMatchObject({ + code: ERROR_CODES.BAD_REQUEST, + message: ERROR_MESSAGES.FILE_UPLOAD_INVALID, + }); + expect(uploadError.body.error.details.field).toBe('photo'); + expect(prismaError.status).toBe(400); + expect(prismaError.body.error.details.code).toBe('P2002'); + expect(badInput.body.error.details.reason).toBe('test'); + }); + + it('hides unknown errors behind a generic 500 response', async () => { + const app = createErrorTestApp(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const response = await request(app).get('/unknown-error'); + + expect(response.status).toBe(500); + expect(response.body.error).toEqual({ + code: ERROR_CODES.INTERNAL_SERVER_ERROR, + message: ERROR_MESSAGES.INTERNAL_SERVER_ERROR, + details: {}, + }); + expect(errorSpy).toHaveBeenCalledOnce(); + errorSpy.mockRestore(); + }); + + it('replaces request values with parsed validation output', async () => { + const app = createErrorTestApp(); + + const valid = await request(app).get('/validated?limit=3'); + const invalid = await request(app).get('/invalid?limit=0'); + + expect(valid.status).toBe(200); + expect(valid.body).toEqual({ limitType: 'number', value: 3 }); + expect(invalid.status).toBe(400); + expect(invalid.body.error.message).toBe(ERROR_MESSAGES.INVALID_REQUEST); + }); + + it('throws when a protected controller requires a missing user', () => { + expect(() => requireUser({} as never)).toThrow(ERROR_MESSAGES.AUTH_REQUIRED); + }); + + it('normalizes response and upload helper output', () => { + expect(pagedResponse([{ id: 'one' }], { limit: 1 })).toEqual({ + data: [{ id: 'one' }], + page: { + limit: 1, + nextCursor: null, + }, + }); + expect(acceptedResponse()).toEqual({ data: { accepted: true } }); + expect(createUploadedFilePublicPath('photo.jpg')).toMatch(/\/photo\.jpg$/); + }); +}); diff --git a/tests/mock-soundlog.service.test.ts b/tests/mock-soundlog.service.test.ts new file mode 100644 index 0000000..0bdaaee --- /dev/null +++ b/tests/mock-soundlog.service.test.ts @@ -0,0 +1,612 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { mockDb, resetMockDb } from '../src/mock/mock-db.js'; +import { mockSoundlogService } from '../src/services/mock-soundlog.service.js'; + +const ownerId = 'mock-user-local'; +const peerId = 'mock-user-peer'; + +function expectHttpError(error: unknown, statusCode: number) { + expect(error).toMatchObject({ statusCode }); +} + +function requireValue(value: T | null | undefined): T { + expect(value).toBeDefined(); + + if (value === null || value === undefined) { + throw new Error('Expected test value to be defined'); + } + + return value; +} + +describe('mockSoundlogService', () => { + beforeEach(() => { + resetMockDb(); + }); + + it('updates profile, ranks nearby places, and prioritizes travel playlists by location', async () => { + const profile = await mockSoundlogService.upsertMyProfile(ownerId, { + birthYear: 1998, + companionType: 'friends', + dislikedArtists: ['skip-me'], + gender: 'none', + locationRecommendationEnabled: false, + preferredGenres: ['인디'], + preferredMoods: ['잔잔한'], + travelStyles: ['산책'], + }); + const places = await mockSoundlogService.getNearbyPlaces({ lat: 35.1532, limit: 2 }); + const playlists = await mockSoundlogService.getFeaturedPlaylists(undefined, { + lat: 35.1532, + limit: 3, + locationRecommendationEnabled: true, + recommendationMode: 'travel', + }); + const placeScopedPlaylists = await mockSoundlogService.getFeaturedPlaylists(undefined, { + limit: 1, + locationRecommendationEnabled: true, + placeId: 'seed-gwangalli', + recommendationMode: 'travel', + }); + const recommendations = await mockSoundlogService.getMoodRecommendations(undefined, { + limit: 1, + moodFilter: '청량한', + preferredGenres: ['K-POP'], + preferredMoods: ['청량한'], + recommendationMode: 'travel', + travelStyles: ['산책'], + }); + const contextualPlaylist = await mockSoundlogService.createContextualPlaylist(ownerId, { + mood: '잔잔한', + state: '산책', + travelMode: 'walk', + }); + const recommendedPlaylist = await mockSoundlogService.getRecommendedPlaylist(ownerId, { + location: { lat: 35.1532, lng: 129.1186 }, + mood: '시원한', + state: '바다', + }); + + expect(profile.completedOnboarding).toBe(true); + expect(profile.preferredGenres).toEqual(['인디']); + expect(profile.dislikedArtists).toEqual(['skip-me']); + expect(places).toHaveLength(2); + expect(places[0].source).toBe('seed'); + expect(places[0].id).toMatch(/^seed-/); + expect(playlists[0].id).toBe('busan-ocean'); + expect(placeScopedPlaylists[0].id).toBe('busan-ocean'); + expect(recommendations[0].track.id).toEqual(expect.any(String)); + expect(contextualPlaylist.context).toMatchObject({ + source: 'seed-fallback', + state: '산책', + travelMode: 'walk', + }); + expect(recommendedPlaylist.context).toMatchObject({ + source: 'seed-fallback', + state: '바다', + }); + }); + + it('stores library state and paginates saved or liked tracks', async () => { + const saved = await mockSoundlogService.updateLibraryTrackState(ownerId, 'moon-seoul', { + action: 'save', + playlistId: 'seoul-night', + }); + const liked = await mockSoundlogService.updateLibraryTrackState(ownerId, 'moon-seoul', { + action: 'like', + }); + const savedTracks = await mockSoundlogService.getLibraryTracks(ownerId, { + kind: 'saved', + limit: 1, + }); + const nextPage = await mockSoundlogService.getLibraryTracks(ownerId, { + cursor: savedTracks.page.nextCursor ?? undefined, + kind: 'all', + limit: 1, + }); + const unliked = await mockSoundlogService.updateLibraryTrackState(ownerId, 'moon-seoul', { + action: 'unlike', + }); + + expect(saved.isSaved).toBe(true); + expect(liked.isLiked).toBe(true); + expect(savedTracks.data[0].track.id).toEqual(expect.any(String)); + expect(savedTracks.data[0].playlist).toMatchObject({ + id: 'seoul-night', + regionName: '서울', + }); + expect(savedTracks.page.limit).toBe(1); + expect(nextPage.data).toHaveLength(1); + expect(unliked.isLiked).toBe(false); + await expect( + mockSoundlogService.updateLibraryTrackState(ownerId, 'missing-track', { action: 'save' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 404); + return true; + }); + }); + + it('creates moment logs with optional photo, custom track fallback, pagination, and idempotency', async () => { + const first = await mockSoundlogService.createMomentLog( + ownerId, + { + artistName: 'Unknown Artist', + createdAt: '2026-07-09T10:00:00.000Z', + lat: 37.51, + lng: 126.99, + moodTags: ['잔잔한'], + note: '바다 앞 산책', + photoPath: '/uploads/photo-a.jpg', + placeCategory: 'beach', + placeId: 'seed-gwangalli', + placeName: '광안리해수욕장', + sessionId: 'session-a', + trackTitle: '새로 발견한 곡', + travelMode: '산책', + }, + 'moment-key-a', + ); + const repeated = await mockSoundlogService.createMomentLog( + ownerId, + { + createdAt: '2026-07-09T11:00:00.000Z', + moodTags: ['신나는'], + placeName: '다른 장소', + }, + 'moment-key-a', + ); + const withoutPhoto = await mockSoundlogService.createMomentLog(ownerId, { + createdAt: '2026-07-09T12:00:00.000Z', + moodTags: [], + placeName: '사진 없는 기록', + sessionId: 'session-a', + trackId: 'seoul-city', + }); + const logs = await mockSoundlogService.getMomentLogs(ownerId, { + limit: 1, + sessionId: 'session-a', + }); + const recent = await mockSoundlogService.getRecentMusicLogs(ownerId, { limit: 2 }); + const firstId = requireValue(first.id); + const withoutPhotoId = requireValue(withoutPhoto.id); + const updated = await mockSoundlogService.updateMomentLog(ownerId, firstId, { + moodTags: ['calm'], + note: '수정된 산책 메모', + placeName: '수정된 광안리', + trackTitle: '수정된 추천곡', + }); + const cleared = await mockSoundlogService.updateMomentLog(ownerId, firstId, { + note: null, + }); + const photoUpdated = await mockSoundlogService.updateMomentLogPhoto( + ownerId, + firstId, + '/uploads/photo-b.jpg', + ); + const photoDeleted = await mockSoundlogService.deleteMomentLogPhoto(ownerId, firstId); + + expect(first.id).toBe(repeated.id); + expect(first.photoUrl).toContain('/uploads/photo-a.jpg'); + expect(requireValue(first.track).title).toBe('새로 발견한 곡'); + expect(first.note).toBe('바다 앞 산책'); + expect(withoutPhoto.photoUrl).toBeUndefined(); + expect(logs.data).toHaveLength(1); + expect(logs.page.nextCursor).toEqual(expect.any(String)); + expect(recent[0].placeName).toEqual(expect.any(String)); + expect(updated.placeName).toBe('수정된 광안리'); + expect(requireValue(updated.track).title).toBe('수정된 추천곡'); + expect(cleared.note).toBeUndefined(); + expect(photoUpdated.photoUrl).toContain('/uploads/photo-b.jpg'); + expect(photoDeleted.photoUrl).toBeUndefined(); + + await mockSoundlogService.deleteMomentLog(ownerId, withoutPhotoId); + const afterDelete = await mockSoundlogService.getMomentLogs(ownerId, { + sessionId: 'session-a', + }); + expect(afterDelete.data.some((item) => item.id === withoutPhotoId)).toBe(false); + await expect( + mockSoundlogService.deleteMomentLog(ownerId, withoutPhotoId), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 404); + return true; + }); + }); + + it('records recommendation events once per event id', async () => { + await mockSoundlogService.createRecommendationEvents(ownerId, { + events: [ + { + context: { placeName: '서울숲' }, + createdAt: '2026-07-09T10:00:00.000Z', + id: 'event-a', + playlistId: 'seoul-night', + sessionId: 'session-a', + trackId: 'seoul-city', + type: 'track_external_open', + value: 'search', + }, + { + context: { duplicate: true }, + createdAt: '2026-07-09T10:01:00.000Z', + id: 'event-a', + sessionId: 'session-a', + type: 'track_external_open', + }, + ], + }); + + expect(mockDb.recommendationEvents.filter((event) => event.id === 'event-a')).toHaveLength(1); + expect(mockDb.recommendationEvents[0].userId).toBe(ownerId); + }); + + it('creates recaps, share payloads, share events, and validates representative tracks', async () => { + const moment = await mockSoundlogService.createMomentLog(ownerId, { + createdAt: '2026-07-09T10:00:00.000Z', + moodTags: ['감성적인'], + photoPath: '/uploads/recap.jpg', + placeName: '서울숲', + sessionId: 'recap-session', + trackId: 'seoul-city', + }); + const momentId = requireValue(moment.id); + const recap = await mockSoundlogService.createRecap( + ownerId, + { + momentLogIds: [momentId], + sessionId: 'recap-session', + title: '서울숲 사운드', + }, + 'recap-key-a', + ); + const recapId = requireValue(recap.id); + const repeated = await mockSoundlogService.createRecap( + ownerId, + { + title: '다른 제목', + }, + 'recap-key-a', + ); + const list = await mockSoundlogService.getRecaps(ownerId, { limit: 1 }); + const share = await mockSoundlogService.getRecapShare(ownerId, recapId); + + await mockSoundlogService.createRecapShareEvent(ownerId, recapId, { + createdAt: '2026-07-09T10:10:00.000Z', + type: 'save_image', + }); + + expect(recap.id).toBe(repeated.id); + expect(recap.title).toBe('서울숲 사운드'); + expect(list.data[0].id).toBe(recapId); + expect(share.moments).toHaveLength(1); + expect(mockDb.recapShareEvents).toHaveLength(1); + await expect( + mockSoundlogService.createRecap(ownerId, { representativeTrackId: 'missing-track' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 404); + return true; + }); + await expect( + mockSoundlogService.getRecapShare(ownerId, 'missing-recap'), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 404); + return true; + }); + }); + + it('handles collaborative travel rooms, invite validation, comments, and room recaps', async () => { + const room = await mockSoundlogService.createTravelRoom(ownerId, { + sessionId: 'trip-session', + title: '강릉 여행방', + visibility: 'private', + }); + + await expect( + mockSoundlogService.getTravelRoom(peerId, room.id), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 404); + return true; + }); + await expect( + mockSoundlogService.joinTravelRoom(peerId, room.id, { inviteCode: 'WRONG' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 400); + return true; + }); + + const joined = await mockSoundlogService.joinTravelRoom(peerId, room.id, { + displayName: '수경', + inviteCode: room.inviteCode, + }); + const joinedByInviteCode = await mockSoundlogService.joinTravelRoomByInviteCode(peerId, { + displayName: '수경', + inviteCode: room.inviteCode, + }); + const momentLog = await mockSoundlogService.createMomentLog(ownerId, { + createdAt: '2026-07-09T10:00:00.000Z', + moodTags: ['시원한'], + placeName: '안목해변', + trackId: 'seoul-city', + }); + const roomMoment = await mockSoundlogService.addTravelRoomMoment(peerId, room.id, { + momentLogId: momentLog.id, + note: '바다 앞에서 듣기 좋았음', + status: 'candidate', + }); + const updated = await mockSoundlogService.updateTravelRoomMoment(ownerId, room.id, roomMoment.id, { + status: 'accepted', + }); + const comment = await mockSoundlogService.addTravelRoomMomentComment(peerId, room.id, roomMoment.id, { + body: '이 곡 대표곡으로 좋아요', + }); + const recap = await mockSoundlogService.createTravelRoomRecap( + ownerId, + room.id, + { + templateId: 'lp', + title: '강릉 공동 Recap', + }, + 'room-recap-key', + ); + const repeatedRecap = await mockSoundlogService.createTravelRoomRecap( + ownerId, + room.id, + { + title: '다른 제목', + }, + 'room-recap-key', + ); + + expect(joined.memberCount).toBe(2); + expect(joinedByInviteCode.id).toBe(room.id); + expect(requireValue(roomMoment.track).id).toBe(requireValue(momentLog.track).id); + expect(updated.status).toBe('accepted'); + expect(updated.commentCount).toBe(0); + expect(comment.body).toBe('이 곡 대표곡으로 좋아요'); + expect(recap.id).toBe(repeatedRecap.id); + expect(recap.roomId).toBe(room.id); + expect(recap.templateId).toBe('lp'); + await expect( + mockSoundlogService.addTravelRoomMoment('not-a-member', room.id, { trackTitle: '곡' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 404); + return true; + }); + await expect( + mockSoundlogService.addTravelRoomMoment(peerId, room.id, { momentLogId: 'missing-moment' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 404); + return true; + }); + }); + + it('shares current tracks on the sound map and protects exact locations', async () => { + const ownerSession = await mockSoundlogService.createTravelSession(ownerId, { + location: { lat: 37.751, lng: 128.875 }, + travelMode: '산책', + }); + const peerSession = await mockSoundlogService.createTravelSession(peerId, { + location: { lat: 37.752, lng: 128.876 }, + travelMode: '카페', + }); + const outsiderId = 'mock-user-outsider'; + const outsiderSession = await mockSoundlogService.createTravelSession(outsiderId, { + location: { lat: 37.752, lng: 128.876 }, + travelMode: '산책', + }); + + await expect( + mockSoundlogService.upsertSoundMapCurrentTrack(ownerId, { + artistName: 'Artist', + location: { lat: 37.751, lng: 128.875 }, + trackTitle: 'No Session', + visibility: 'nearby', + }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 400); + return true; + }); + + const ownerPin = await mockSoundlogService.upsertSoundMapCurrentTrack(ownerId, { + location: { lat: 37.75123, lng: 128.87567 }, + moodTags: ['잔잔한'], + placeName: '강릉역', + sessionId: ownerSession.id, + trackId: 'seoul-city', + visibility: 'companions', + }); + const peerPin = await mockSoundlogService.upsertSoundMapCurrentTrack(peerId, { + artistName: 'Peer Artist', + location: { lat: 37.75234, lng: 128.87678 }, + moodTags: ['잔잔한'], + placeName: '안목해변', + sessionId: peerSession.id, + trackTitle: 'Peer Track', + visibility: 'nearby', + }); + const outsiderCompanionPin = await mockSoundlogService.upsertSoundMapCurrentTrack(outsiderId, { + artistName: 'Outsider Artist', + location: { lat: 37.7521, lng: 128.8761 }, + placeName: '비동행자 위치', + sessionId: outsiderSession.id, + trackTitle: 'Hidden Companion Track', + visibility: 'companions', + }); + const pins = await mockSoundlogService.getSoundMapPins(ownerId, { + lat: 37.751, + lng: 128.875, + radiusMeters: 3000, + }); + const nearbyWithoutLocation = await mockSoundlogService.getNearbySoundMatches(ownerId, { + mood: '잔잔한', + state: '산책', + }); + const nearby = await mockSoundlogService.getNearbySoundMatches(ownerId, { + lat: 37.751, + lng: 128.875, + mood: '잔잔한', + radiusMeters: 3000, + state: '산책', + }); + const matches = await mockSoundlogService.getMusicMatches(ownerId, { + lat: 37.751, + lng: 128.875, + mood: '잔잔한', + state: '산책', + }); + + expect(ownerPin.isMine).toBe(true); + expect(ownerPin.location.lat).toBe(37.75123); + expect(peerPin.isMine).toBe(true); + expect(pins.map((pin) => pin.id)).toEqual(expect.arrayContaining([ownerPin.id, peerPin.id])); + expect(pins.map((pin) => pin.id)).not.toContain(outsiderCompanionPin.id); + expect(pins.find((pin) => pin.id === peerPin.id)?.location.lat).toBeCloseTo(37.75); + expect(nearbyWithoutLocation).toEqual([]); + expect(nearby[0].targetPinId).toBe(peerPin.id); + expect(nearby[0].matchScore).toBeGreaterThanOrEqual(80); + expect(matches[0].safety.exactLocationHidden).toBe(true); + }); + + it('deduplicates, authorizes, accepts, blocks, and reports travel mate requests', async () => { + const ownerSession = await mockSoundlogService.createTravelSession(ownerId, { + location: { lat: 37.751, lng: 128.875 }, + travelMode: '산책', + }); + const peerSession = await mockSoundlogService.createTravelSession(peerId, { + location: { lat: 37.752, lng: 128.876 }, + travelMode: '산책', + }); + const ownerPin = await mockSoundlogService.upsertSoundMapCurrentTrack(ownerId, { + location: { lat: 37.751, lng: 128.875 }, + sessionId: ownerSession.id, + trackId: 'seoul-city', + visibility: 'nearby', + }); + const peerPin = await mockSoundlogService.upsertSoundMapCurrentTrack(peerId, { + location: { lat: 37.752, lng: 128.876 }, + sessionId: peerSession.id, + trackTitle: 'Peer Track', + visibility: 'nearby', + }); + + await expect( + mockSoundlogService.createTravelMateRequest(ownerId, { messageTemplate: 'liked_track' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 400); + return true; + }); + await expect( + mockSoundlogService.createTravelMateRequest(ownerId, { + messageTemplate: 'liked_track', + targetPinId: ownerPin.id, + }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 400); + return true; + }); + + const request = await mockSoundlogService.createTravelMateRequest(ownerId, { + messageTemplate: 'liked_track', + targetPinId: peerPin.id, + }); + const duplicate = await mockSoundlogService.createTravelMateRequest(ownerId, { + messageTemplate: 'walk_together', + targetPinId: peerPin.id, + }); + + await expect( + mockSoundlogService.updateTravelMateRequest(ownerId, request.id, { action: 'accept' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 403); + return true; + }); + + const accepted = await mockSoundlogService.updateTravelMateRequest(peerId, request.id, { + action: 'accept', + }); + + await expect( + mockSoundlogService.updateTravelMateRequest(peerId, request.id, { action: 'decline' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 400); + return true; + }); + + await mockSoundlogService.reportCommunityTarget(ownerId, { + details: '불편한 메시지', + reason: 'safety', + targetPinId: peerPin.id, + }); + await mockSoundlogService.blockCommunityUser(ownerId, { targetPinId: peerPin.id }); + + const afterBlock = await mockSoundlogService.getNearbySoundMatches(ownerId, { + lat: 37.751, + lng: 128.875, + radiusMeters: 3000, + }); + + expect(request.id).toBe(duplicate.id); + expect(accepted.status).toBe('accepted'); + expect(mockDb.communityReports).toHaveLength(1); + expect(mockDb.communityBlocks).toHaveLength(1); + expect(afterBlock).toEqual([]); + }); + + it('updates travel sessions and returns regional sound trends', async () => { + const session = await mockSoundlogService.createTravelSession(ownerId, { + location: { lat: 35.1532, lng: 129.1186 }, + startedAt: '2026-07-09T09:00:00.000Z', + travelMode: '바다', + }); + const livePin = await mockSoundlogService.upsertSoundMapCurrentTrack(ownerId, { + location: { lat: 35.1532, lng: 129.1186 }, + sessionId: session.id, + trackId: 'seoul-city', + visibility: 'nearby', + }); + const pinsBeforeEnding = await mockSoundlogService.getSoundMapPins(ownerId, { + lat: 35.1532, + lng: 129.1186, + radiusMeters: 3000, + }); + const ended = await mockSoundlogService.updateTravelSession(ownerId, session.id, { + endedAt: '2026-07-09T11:00:00.000Z', + location: { lat: 35.16, lng: 129.12 }, + status: 'ended', + }); + const pinsAfterEnding = await mockSoundlogService.getSoundMapPins(ownerId, { + lat: 35.1532, + lng: 129.1186, + radiusMeters: 3000, + }); + const trend = await mockSoundlogService.getRegionSoundTrend({ + period: 'weekly', + regionCode: 'KR-26', + }); + + expect(session.status).toBe('active'); + expect(pinsBeforeEnding.map((pin) => pin.id)).toContain(livePin.id); + expect(ended.status).toBe('ended'); + expect(ended.endedAt).toBe('2026-07-09T11:00:00.000Z'); + expect(pinsAfterEnding.map((pin) => pin.id)).not.toContain(livePin.id); + expect(trend.topTracks.length).toBeGreaterThan(0); + await expect( + mockSoundlogService.updateTravelSession(ownerId, session.id, { status: 'active' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 400); + return true; + }); + await expect( + mockSoundlogService.updateTravelSession(ownerId, 'missing-session', { status: 'ended' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 404); + return true; + }); + await expect( + mockSoundlogService.getRegionSoundTrend({ period: 'daily', regionCode: 'missing' }), + ).rejects.toSatisfy((error) => { + expectHttpError(error, 404); + return true; + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index ff73ea5..594659a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,32 @@ import { defineConfig } from 'vitest/config'; +const coverageExclude = [ + 'src/server.ts', + 'src/types/**', +]; + +if (process.env.USE_MOCK_DB === 'true') { + coverageExclude.push('src/services/soundlog.service.ts'); +} + export default defineConfig({ test: { + coverage: { + exclude: coverageExclude, + include: ['src/**/*.ts'], + provider: 'v8', + reporter: ['text', 'json-summary', 'lcov'], + thresholds: { + branches: 50, + functions: 70, + lines: 70, + statements: 70, + }, + }, environment: 'node', globals: true, include: ['tests/**/*.test.ts'], pool: 'threads', + testTimeout: 15_000, }, });