From 0b2204c819aab3776784cb55785810adccadb381 Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 7 Jul 2026 17:06:51 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=B4=EB=93=9C=EB=A7=B5?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B3=84=EC=95=BD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openapi/soundlog-api.yaml | 2 + src/validators/api.validators.ts | 2 + tests/api.test.ts | 74 +++++++++++++++++++++++++++++--- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index 6b32f0c..71982ac 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -1629,6 +1629,8 @@ components: - track_unsave - playlist_open - mood_filter_change + - live_track_shared + - nearby_sound_opened - recommendation_mode_change - top_filter_change - recap_representative_track_select diff --git a/src/validators/api.validators.ts b/src/validators/api.validators.ts index a442afe..86cd580 100644 --- a/src/validators/api.validators.ts +++ b/src/validators/api.validators.ts @@ -256,6 +256,8 @@ export const recommendationEventValidators = { 'track_unsave', 'playlist_open', 'mood_filter_change', + 'live_track_shared', + 'nearby_sound_opened', 'recommendation_mode_change', 'top_filter_change', 'recap_representative_track_select', diff --git a/tests/api.test.ts b/tests/api.test.ts index 974ec02..aef7ce7 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -71,6 +71,30 @@ function findSecretLeaks(value: unknown, secret: string, path = '$'): string[] { return value === secret ? [path] : []; } +async function createTestMomentLog(input: { + authHeader: string; + filename: string; + placeName: string; + sessionId?: string; + trackId?: string; +}) { + const response = await request(app) + .post('/v1/moment-logs') + .set('Authorization', input.authHeader) + .field('createdAt', new Date().toISOString()) + .field('moodTags', 'fresh,calm') + .field('placeName', input.placeName) + .field('sessionId', input.sessionId ?? '') + .field('trackId', input.trackId ?? 'seoul-city') + .attach('photo', Buffer.from('fake-image'), { + filename: input.filename, + contentType: 'image/jpeg', + }); + + expect(response.status).toBe(201); + return response.body.data; +} + describe('Soundlog API', () => { let accessToken: string; let authHeader: string; @@ -287,6 +311,14 @@ describe('Soundlog API', () => { expect(mood.status).toBe(200); expect(mood.body.data[0].track).toBeDefined(); + await createTestMomentLog({ + authHeader, + filename: 'recent-music-log.jpg', + placeName: '최근 로그 테스트 장소', + sessionId: `recent-session-${Date.now()}`, + trackId: 'seoul-city', + }); + const recent = await request(app) .get('/v1/home/recent-music-logs') .set('Authorization', authHeader); @@ -385,11 +417,22 @@ describe('Soundlog API', () => { { id: `event-${Date.now()}`, sessionId: 'seed-session', - type: 'track_external_open', + type: 'live_track_shared', + trackId: 'seoul-city', + playlistId: 'seoul-night', + context: { moodFilter: '전체', placeName: '서울 야경 산책' }, + createdAt: new Date().toISOString(), + value: 'companions', + }, + { + id: `event-nearby-${Date.now()}`, + sessionId: 'seed-session', + type: 'nearby_sound_opened', trackId: 'seoul-city', playlistId: 'seoul-night', - context: { moodFilter: '전체' }, + context: { moodFilter: '잔잔한', placeName: '서울 야경 산책' }, createdAt: new Date().toISOString(), + value: 'nearby', }, ], }); @@ -399,25 +442,42 @@ describe('Soundlog API', () => { }); it('handles recap APIs', async () => { - const list = await request(app).get('/v1/recaps').set('Authorization', authHeader); - expect(list.status).toBe(200); - expect(list.body.data.length).toBeGreaterThan(0); + const recapSessionId = `recap-session-${Date.now()}`; + + await createTestMomentLog({ + authHeader, + filename: 'recap-moment-1.jpg', + placeName: '리캡 테스트 장소', + sessionId: recapSessionId, + trackId: 'seoul-night-track', + }); + await createTestMomentLog({ + authHeader, + filename: 'recap-moment-2.jpg', + placeName: '리캡 테스트 장소', + sessionId: recapSessionId, + trackId: 'seoul-night-track', + }); const idempotencyKey = `recap-${Date.now()}`; const created = await request(app) .post('/v1/recaps') .set('Authorization', authHeader) .set('Idempotency-Key', idempotencyKey) - .send({ templateId: 'album', sessionId: 'seed-session', title: '테스트 리캡' }); + .send({ templateId: 'album', sessionId: recapSessionId, title: '테스트 리캡' }); expect(created.status).toBe(201); createdRecapId = created.body.data.id; expect(created.body.data.representativeTrack.id).toBe('seoul-night-track'); + const list = await request(app).get('/v1/recaps').set('Authorization', authHeader); + expect(list.status).toBe(200); + expect(list.body.data.some((recap: { id: string }) => recap.id === createdRecapId)).toBe(true); + const duplicate = await request(app) .post('/v1/recaps') .set('Authorization', authHeader) .set('Idempotency-Key', idempotencyKey) - .send({ templateId: 'album', sessionId: 'seed-session', title: '중복 리캡' }); + .send({ templateId: 'album', sessionId: recapSessionId, title: '중복 리캡' }); expect(duplicate.status).toBe(201); expect(duplicate.body.data.id).toBe(createdRecapId); From 7e6a05b0e147f8d42247e0ff9db40b40fc66f02f Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 7 Jul 2026 17:58:09 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=EB=A7=B5=20=EC=BB=A4=EB=AE=A4=EB=8B=88=ED=8B=B0=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openapi/soundlog-api.yaml | 2294 +++++++++++------ .../migration.sql | 111 + prisma/models/auth.prisma | 10 + prisma/models/community.prisma | 98 + prisma/seed.ts | 8 + src/constants/error.constants.ts | 4 + src/controllers/community.controller.ts | 99 + src/controllers/index.ts | 1 + src/mock/mock-db.ts | 82 + src/routes/index.ts | 94 + src/services/mock-soundlog.service.ts | 507 ++++ src/services/soundlog.service.ts | 677 +++++ src/validators/api.validators.ts | 85 + tests/api.test.ts | 177 ++ 14 files changed, 3520 insertions(+), 727 deletions(-) create mode 100644 prisma/migrations/20260707000000_community_features/migration.sql create mode 100644 prisma/models/community.prisma create mode 100644 src/controllers/community.controller.ts diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index 71982ac..6937d60 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -772,1045 +772,1885 @@ paths: "404": $ref: "#/components/responses/NotFound" - /v1/travel-sessions: + /v1/travel-rooms: post: tags: - - TravelSessions - summary: 여행 세션 시작 - operationId: createTravelSession + - Community + summary: 공동 여행방 생성 + operationId: createTravelRoom requestBody: - required: false + required: true content: application/json: schema: - $ref: "#/components/schemas/TravelSessionCreateRequest" + $ref: "#/components/schemas/TravelRoomCreateRequest" responses: "201": - description: 시작된 여행 세션 + description: 생성된 공동 여행방 content: application/json: schema: - $ref: "#/components/schemas/TravelSessionResponse" + $ref: "#/components/schemas/TravelRoomResponse" "401": $ref: "#/components/responses/Unauthorized" - /v1/travel-sessions/{sessionId}: - patch: + /v1/travel-rooms/{roomId}: + get: tags: - - TravelSessions - summary: 여행 세션 상태 변경 - operationId: updateTravelSession + - Community + summary: 공동 여행방 조회 + operationId: getTravelRoom parameters: - - name: sessionId + - name: roomId + in: path + required: true + schema: + type: string + responses: + "200": + description: 공동 여행방 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /v1/travel-rooms/{roomId}/join: + post: + tags: + - Community + summary: 공동 여행방 참여 + operationId: joinTravelRoom + parameters: + - name: roomId in: path required: true schema: type: string requestBody: - required: true + required: false content: application/json: schema: - $ref: "#/components/schemas/TravelSessionUpdateRequest" + $ref: "#/components/schemas/TravelRoomJoinRequest" responses: "200": - description: 변경된 여행 세션 + description: 참여 후 공동 여행방 content: application/json: schema: - $ref: "#/components/schemas/TravelSessionResponse" - "400": - $ref: "#/components/responses/BadRequest" + $ref: "#/components/schemas/TravelRoomResponse" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" - /v1/trends/regions/{regionCode}/sound: - get: + /v1/travel-rooms/{roomId}/moments: + post: tags: - - Trends - security: [] - summary: 지역 사운드 트렌드 조회 - description: 초기 사용자 또는 비로그인 사용자에게 제공할 지역 기반 인기 음악/무드 집계입니다. - operationId: getRegionSoundTrend + - Community + summary: 공동 Recap 후보 순간 추가 + operationId: addTravelRoomMoment parameters: - - name: regionCode + - name: roomId in: path required: true - description: 행정구역 또는 서비스 내부 지역 코드. schema: type: string - example: KR-26 - - name: period - in: query + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomMomentCreateRequest" + responses: + "201": + description: 추가된 공동 순간 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomMomentResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /v1/travel-rooms/{roomId}/recaps: + post: + tags: + - Community + summary: 공동 Recap 생성 + operationId: createTravelRoomRecap + parameters: + - name: roomId + in: path + required: true schema: type: string - enum: - - daily - - weekly - - monthly - default: weekly + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomRecapCreateRequest" responses: - "200": - description: 지역 사운드 트렌드 + "201": + description: 생성된 공동 Recap content: application/json: schema: - $ref: "#/components/schemas/RegionSoundTrendResponse" + $ref: "#/components/schemas/RecapItemResponse" + "401": + $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT + /v1/sound-map: + get: + tags: + - Community + summary: Live Sound Map 핀 조회 + operationId: getSoundMap + parameters: + - name: lat + in: query + schema: + type: number + - name: lng + in: query + schema: + type: number + - name: radiusMeters + in: query + schema: + type: integer + default: 3000 + - name: visibility + in: query + schema: + type: string + enum: [companions, nearby] + responses: + "200": + description: 지도 핀 목록 + content: + application/json: + schema: + $ref: "#/components/schemas/SoundMapPinListResponse" + "401": + $ref: "#/components/responses/Unauthorized" - parameters: - Lat: - name: lat - in: query - required: true - description: 위도 - schema: - type: number - format: double - minimum: -90 - maximum: 90 - example: 35.1532 - Lng: - name: lng - in: query - required: true - description: 경도 - schema: - type: number - format: double - minimum: -180 - maximum: 180 - example: 129.1186 - OptionalLat: - name: lat - in: query - required: false - description: 위도 - schema: - type: number - format: double - minimum: -90 - maximum: 90 - OptionalLng: - name: lng - in: query - required: false - description: 경도 - schema: - type: number - format: double - minimum: -180 - maximum: 180 - Cursor: - name: cursor - in: query - required: false - description: 다음 페이지 조회용 커서 - schema: - type: string - Limit: - name: limit - in: query - required: false - description: 조회 개수 - schema: - type: integer - minimum: 1 - maximum: 50 - default: 20 - IdempotencyKey: - name: Idempotency-Key - in: header - required: false - description: 중복 요청 방지를 위한 클라이언트 생성 키 - schema: - type: string - maxLength: 128 + /v1/sound-map/current-track: + post: + tags: + - Community + summary: 현재 선택 곡을 지도에 공개 + operationId: upsertSoundMapCurrentTrack + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SoundMapCurrentTrackRequest" + responses: + "202": + description: 공개 상태 저장 + content: + application/json: + schema: + $ref: "#/components/schemas/SoundMapPinResponse" + "401": + $ref: "#/components/responses/Unauthorized" - responses: - BadRequest: - description: 잘못된 요청 - content: - application/json: + /v1/sound-map/nearby: + get: + tags: + - Community + summary: 주변 익명 사운드 핀 조회 + operationId: getNearbySounds + parameters: + - name: lat + in: query schema: - $ref: "#/components/schemas/ErrorResponse" - Unauthorized: - description: 인증 실패 또는 토큰 만료 - content: - application/json: + type: number + - name: lng + in: query schema: - $ref: "#/components/schemas/ErrorResponse" - NotFound: - description: 리소스를 찾을 수 없음 - content: - application/json: + type: number + - name: radiusMeters + in: query schema: - $ref: "#/components/schemas/ErrorResponse" + type: integer + default: 3000 + - name: state + in: query + schema: + type: string + - name: mood + in: query + schema: + type: string + responses: + "200": + description: 주변 익명 사운드 핀 + content: + application/json: + schema: + $ref: "#/components/schemas/SoundMapPinListResponse" + "401": + $ref: "#/components/responses/Unauthorized" - schemas: - HealthResponse: - type: object - required: - - data - properties: - data: - type: object - required: - - status - - checkedAt - properties: - status: - type: string - enum: - - ok - checkedAt: - type: string - format: date-time + /v1/music-matches: + get: + tags: + - Community + summary: 주변 음악 취향 매칭 후보 조회 + operationId: getMusicMatches + parameters: + - name: lat + in: query + schema: + type: number + - name: lng + in: query + schema: + type: number + - name: radiusMeters + in: query + schema: + type: integer + default: 3000 + - name: state + in: query + schema: + type: string + - name: mood + in: query + schema: + type: string + responses: + "200": + description: 취향 매칭 후보 + content: + application/json: + schema: + $ref: "#/components/schemas/MusicMatchListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + + /v1/travel-mate-requests: + post: + tags: + - Community + summary: 동행 매칭 요청 생성 + operationId: createTravelMateRequest + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TravelMateRequestCreateRequest" + responses: + "201": + description: 생성된 동행 매칭 요청 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelMateRequestResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + + /v1/travel-mate-requests/{requestId}: + patch: + tags: + - Community + summary: 동행 매칭 요청 상태 변경 + operationId: updateTravelMateRequest + parameters: + - name: requestId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TravelMateRequestUpdateRequest" + responses: + "200": + description: 변경된 동행 매칭 요청 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelMateRequestResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /v1/community/blocks: + post: + tags: + - Community + summary: 커뮤니티 사용자 차단 + operationId: blockCommunityUser + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + targetUserId: + type: string + description: 대상 사용자 ID를 알고 있을 때 사용합니다. + targetPinId: + type: string + description: 익명 사운드맵 핀을 기준으로 서버가 대상 사용자를 찾아 차단합니다. + responses: + "202": + description: 차단 요청 수락 + content: + application/json: + schema: + $ref: "#/components/schemas/AcceptedResponse" + "401": + $ref: "#/components/responses/Unauthorized" + + /v1/community/reports: + post: + tags: + - Community + summary: 커뮤니티 신고 + operationId: reportCommunityTarget + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommunityReportRequest" + responses: + "202": + description: 신고 요청 수락 + content: + application/json: + schema: + $ref: "#/components/schemas/AcceptedResponse" + "401": + $ref: "#/components/responses/Unauthorized" + + /v1/travel-sessions: + post: + tags: + - TravelSessions + summary: 여행 세션 시작 + operationId: createTravelSession + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/TravelSessionCreateRequest" + responses: + "201": + description: 시작된 여행 세션 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelSessionResponse" + "401": + $ref: "#/components/responses/Unauthorized" + + /v1/travel-sessions/{sessionId}: + patch: + tags: + - TravelSessions + summary: 여행 세션 상태 변경 + operationId: updateTravelSession + parameters: + - name: sessionId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TravelSessionUpdateRequest" + responses: + "200": + description: 변경된 여행 세션 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelSessionResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /v1/trends/regions/{regionCode}/sound: + get: + tags: + - Trends + security: [] + summary: 지역 사운드 트렌드 조회 + description: 초기 사용자 또는 비로그인 사용자에게 제공할 지역 기반 인기 음악/무드 집계입니다. + operationId: getRegionSoundTrend + parameters: + - name: regionCode + in: path + required: true + description: 행정구역 또는 서비스 내부 지역 코드. + schema: + type: string + example: KR-26 + - name: period + in: query + schema: + type: string + enum: + - daily + - weekly + - monthly + default: weekly + responses: + "200": + description: 지역 사운드 트렌드 + content: + application/json: + schema: + $ref: "#/components/schemas/RegionSoundTrendResponse" + "404": + $ref: "#/components/responses/NotFound" + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + Lat: + name: lat + in: query + required: true + description: 위도 + schema: + type: number + format: double + minimum: -90 + maximum: 90 + example: 35.1532 + Lng: + name: lng + in: query + required: true + description: 경도 + schema: + type: number + format: double + minimum: -180 + maximum: 180 + example: 129.1186 + OptionalLat: + name: lat + in: query + required: false + description: 위도 + schema: + type: number + format: double + minimum: -90 + maximum: 90 + OptionalLng: + name: lng + in: query + required: false + description: 경도 + schema: + type: number + format: double + minimum: -180 + maximum: 180 + Cursor: + name: cursor + in: query + required: false + description: 다음 페이지 조회용 커서 + schema: + type: string + Limit: + name: limit + in: query + required: false + description: 조회 개수 + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + description: 중복 요청 방지를 위한 클라이언트 생성 키 + schema: + type: string + maxLength: 128 + + responses: + BadRequest: + description: 잘못된 요청 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Unauthorized: + description: 인증 실패 또는 토큰 만료 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: 리소스를 찾을 수 없음 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + schemas: + HealthResponse: + type: object + required: + - data + properties: + data: + type: object + required: + - status + - checkedAt + properties: + status: + type: string + enum: + - ok + checkedAt: + type: string + format: date-time + + DbTestRecordCreateRequest: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 120 + default: swagger-db-test + example: swagger-smoke-test + payload: + type: object + additionalProperties: true + example: + source: swagger + note: DB insert 확인용 + + DbTestRecord: + type: object + required: + - id + - label + - createdAt + - database + - table + properties: + id: + type: string + example: cmabc123def456 + label: + type: string + example: swagger-smoke-test + payload: + type: object + additionalProperties: true + createdAt: + type: string + format: date-time + database: + type: string + enum: + - postgres + - mock-db + example: postgres + table: + type: string + enum: + - DbTestRecord + example: DbTestRecord + + DbTestRecordResponse: + type: object + required: + - data + properties: + data: + $ref: "#/components/schemas/DbTestRecord" + + AcceptedResponse: + type: object + required: + - data + properties: + data: + type: object + required: + - accepted + properties: + accepted: + type: boolean + example: true + + ErrorResponse: + type: object + required: + - error + properties: + error: + type: object + required: + - code + - message + properties: + code: + type: string + example: NOT_FOUND + message: + type: string + example: 요청한 리소스를 찾을 수 없습니다. + details: + type: object + additionalProperties: true + + PageInfo: + type: object + required: + - limit + properties: + limit: + type: integer + example: 20 + nextCursor: + type: + - string + - "null" + example: cursor_2026_05_26 + + GeoPoint: + type: object + required: + - lat + - lng + properties: + lat: + type: number + format: double + example: 35.1532 + lng: + type: number + format: double + example: 129.1186 + + TravelMode: + type: string + description: 여행 상황 모드 + enum: + - walk + - drive + - cafe + - ocean + - festival + - night + + MusicRecommendationMode: + type: string + description: 홈 추천 토글 모드. `everyday`는 평소 취향, `travel`은 관광 장소 맥락을 우선합니다. + enum: + - everyday + - travel + + MoodTag: + type: string + description: 추천/기록에 사용하는 무드 태그 + enum: + - calm + - fresh + - emotional + - active + - local + + PlaceContext: + type: object + required: + - id + - source + - title + properties: + id: + type: string + description: 한국관광공사 contentid 또는 Soundlog 내부 장소 ID + example: "126078" + title: + type: string + example: 광안리해수욕장 + address: + type: string + example: 부산광역시 수영구 광안해변로 219 + category: + type: string + description: 관광공사 카테고리 또는 Soundlog 정규화 카테고리 + example: 해변 + contentType: + type: string + example: 관광지 + distanceMeters: + type: integer + example: 430 + imageUrl: + type: string + format: uri + location: + $ref: "#/components/schemas/GeoPoint" + overview: + type: string + example: 부산을 대표하는 도심 해변 관광지입니다. + source: + type: string + enum: + - tour-api + - seed + - user - DbTestRecordCreateRequest: + Track: type: object + required: + - id + - title + - artist properties: - label: + id: type: string - minLength: 1 - maxLength: 120 - default: swagger-db-test - example: swagger-smoke-test - payload: + example: track_001 + title: + type: string + example: Ocean Drive + artist: + type: string + example: Soundlog Curator + fallbackColor: + type: string + example: "#31206D" + albumImageUrl: + type: string + format: uri + externalUrl: + type: string + format: uri + platformUrls: type: object - additionalProperties: true + properties: + melon: + type: string + format: uri + youtubeMusic: + type: string + format: uri + isLiked: + type: boolean + isSaved: + type: boolean + + FeaturedPlaylist: + type: object + required: + - id + - regionName + - description + - trackCount + - durationText + properties: + id: + type: string + example: busan-ocean-walk + regionName: + type: string + example: 부산 + description: + type: string + example: 부산광역시에 어울리는 노래를 추천해드립니다. + trackCount: + type: integer + example: 12 + durationText: + type: string + example: 40:00분 + source: + type: string + enum: + - location + - trend + - personalized + example: location + + MoodRecommendation: + type: object + required: + - id + - title + - color + - track + properties: + id: + type: string + example: fresh-drive-pop + title: + type: string + example: Music Genre + subtitle: + type: string + example: 드라이브에 어울리는 청량한 팝 + color: + type: string + example: "#31206D" + genres: + type: array + items: + type: string + example: + - 팝 + - K-POP + moods: + type: array + items: + type: string + example: + - 청량한 + - 신나는 + travelStyles: + type: array + items: + type: string + example: + - 드라이브 + playlistId: + type: string + description: 추천 카드 선택 시 바로 열 수 있는 플레이리스트 ID. 없으면 앱은 트랙 미리 재생으로 폴백합니다. + example: drive + track: + $ref: "#/components/schemas/Track" + + PlaylistCuration: + type: object + required: + - id + - regionName + - reason + - trackCount + - durationText + - tracks + properties: + id: + type: string + example: busan-ocean-walk + regionName: + type: string + example: 부산 + placeName: + type: string + example: 광안리해수욕장 + reason: + type: string + example: 광안리 해변 산책과 어울리는 청량한 플레이리스트예요. + coverImageUrl: + type: string + format: uri + backgroundImageUrl: + type: string + format: uri + trackCount: + type: integer + example: 12 + durationText: + type: string + example: 40:00분 + tracks: + type: array + items: + $ref: "#/components/schemas/Track" + context: + $ref: "#/components/schemas/RecommendationContext" + + MusicLogItem: + type: object + required: + - id + - placeName + - trackTitle + - artistName + - createdAt + properties: + id: + type: string + example: log_001 + placeName: + type: string + example: 광안리해수욕장 + trackTitle: + type: string + example: Ocean Drive + artistName: + type: string + example: Soundlog Curator + createdAt: + type: string + format: date-time + imageUrl: + type: string + format: uri + recapShareId: + type: string + example: recap_001 + + UserProfileInput: + type: object + required: + - locationRecommendationEnabled + - preferredGenres + - preferredMoods + - travelStyles + properties: + companionType: + type: string + enum: + - solo + - friends + - couple + - family + example: friends + locationRecommendationEnabled: + type: boolean + example: true + preferredGenres: + type: array + items: + type: string + example: + - K-POP + - 인디 + preferredMoods: + type: array + items: + type: string + example: + - 청량한 + - 잔잔한 + travelStyles: + type: array + items: + type: string example: - source: swagger - note: DB insert 확인용 + - 산책 + - 카페 투어 + dislikedArtists: + type: array + items: + type: string + birthYear: + type: integer + minimum: 1900 + maximum: 2026 + gender: + type: string + enum: + - female + - male + - non_binary + - undisclosed - DbTestRecord: + UserProfile: + allOf: + - $ref: "#/components/schemas/UserProfileInput" + - type: object + required: + - completedOnboarding + properties: + completedOnboarding: + type: boolean + example: true + updatedAt: + type: string + format: date-time + + RecommendationContext: type: object - required: - - id - - label - - createdAt - - database - - table properties: - id: + moodFilter: type: string - example: cmabc123def456 - label: + example: 청량한 + recommendationMode: + $ref: "#/components/schemas/MusicRecommendationMode" + placeCategory: type: string - example: swagger-smoke-test - payload: - type: object - additionalProperties: true - createdAt: + example: 해변 + placeId: type: string - format: date-time - database: + example: "126078" + placeName: type: string - enum: - - postgres - - mock-db - example: postgres - table: + example: 광안리해수욕장 + topFilter: type: string - enum: - - DbTestRecord - example: DbTestRecord + example: 전체 + travelMode: + $ref: "#/components/schemas/TravelMode" - DbTestRecordResponse: + ContextualPlaylistRequest: type: object - required: - - data properties: - data: - $ref: "#/components/schemas/DbTestRecord" + location: + $ref: "#/components/schemas/GeoPoint" + placeId: + type: string + travelMode: + $ref: "#/components/schemas/TravelMode" + moodTags: + type: array + items: + $ref: "#/components/schemas/MoodTag" + preferredGenres: + type: array + items: + type: string + preferredMoods: + type: array + items: + type: string + excludeTrackIds: + type: array + items: + type: string - AcceptedResponse: + LibraryTrackUpdateRequest: type: object required: - - data + - action properties: - data: - type: object - required: - - accepted - properties: - accepted: - type: boolean - example: true + action: + type: string + enum: + - like + - unlike + - save + - unsave + playlistId: + type: string + context: + $ref: "#/components/schemas/RecommendationContext" - ErrorResponse: + LibraryTrackState: type: object required: - - error + - trackId + - isLiked + - isSaved properties: - error: - type: object - required: - - code - - message - properties: - code: - type: string - example: NOT_FOUND - message: - type: string - example: 요청한 리소스를 찾을 수 없습니다. - details: - type: object - additionalProperties: true + trackId: + type: string + isLiked: + type: boolean + isSaved: + type: boolean + updatedAt: + type: string + format: date-time - PageInfo: + LibraryTrackRecord: type: object required: - - limit + - createdAt + - track properties: - limit: - type: integer - example: 20 - nextCursor: - type: - - string - - "null" - example: cursor_2026_05_26 + createdAt: + type: string + format: date-time + playlistId: + type: string + kind: + type: string + enum: + - liked + - saved + track: + $ref: "#/components/schemas/Track" - GeoPoint: + MomentLogCreateForm: type: object required: - - lat - - lng + - photo + - createdAt + - moodTags properties: + photo: + type: string + format: binary + description: 카메라 촬영 이미지 파일 + createdAt: + type: string + format: date-time + sessionId: + type: string lat: type: number format: double - example: 35.1532 lng: type: number format: double - example: 129.1186 - - TravelMode: - type: string - description: 여행 상황 모드 - enum: - - walk - - drive - - cafe - - ocean - - festival - - night - - MusicRecommendationMode: - type: string - description: 홈 추천 토글 모드. `everyday`는 평소 취향, `travel`은 관광 장소 맥락을 우선합니다. - enum: - - everyday - - travel - - MoodTag: - type: string - description: 추천/기록에 사용하는 무드 태그 - enum: - - calm - - fresh - - emotional - - active - - local + placeId: + type: string + placeName: + type: string + placeCategory: + type: string + trackId: + type: string + trackTitle: + type: string + artistName: + type: string + travelMode: + $ref: "#/components/schemas/TravelMode" + moodTags: + type: array + items: + $ref: "#/components/schemas/MoodTag" - PlaceContext: + MomentLog: type: object required: - id - - source - - title + - photoUrl + - createdAt + - moodTags + - syncStatus properties: id: type: string - description: 한국관광공사 contentid 또는 Soundlog 내부 장소 ID - example: "126078" - title: - type: string - example: 광안리해수욕장 - address: - type: string - example: 부산광역시 수영구 광안해변로 219 - category: + example: moment_001 + photoUrl: type: string - description: 관광공사 카테고리 또는 Soundlog 정규화 카테고리 - example: 해변 - contentType: + format: uri + createdAt: type: string - example: 관광지 - distanceMeters: - type: integer - example: 430 - imageUrl: + format: date-time + sessionId: type: string - format: uri location: $ref: "#/components/schemas/GeoPoint" - overview: + placeCategory: type: string - example: 부산을 대표하는 도심 해변 관광지입니다. + placeId: + type: string + placeName: + type: string + track: + $ref: "#/components/schemas/Track" + travelMode: + $ref: "#/components/schemas/TravelMode" + moodTags: + type: array + items: + $ref: "#/components/schemas/MoodTag" source: type: string enum: - - tour-api - - seed - - user + - camera + syncStatus: + type: string + enum: + - pending + - synced + - failed + example: synced - Track: + RecommendationEventType: + type: string + enum: + - track_external_open + - track_like + - track_unlike + - track_save + - track_unsave + - playlist_open + - mood_filter_change + - live_track_shared + - nearby_sound_opened + - recommendation_mode_change + - top_filter_change + - recap_representative_track_select + + RecommendationEvent: type: object required: - id - - title - - artist + - sessionId + - type + - context + - createdAt properties: id: type: string - example: track_001 - title: + description: 클라이언트가 생성한 이벤트 ID + example: event_001 + sessionId: type: string - example: Ocean Drive - artist: + example: session_001 + type: + $ref: "#/components/schemas/RecommendationEventType" + trackId: type: string - example: Soundlog Curator - fallbackColor: + playlistId: type: string - example: "#31206D" - albumImageUrl: + value: type: string - format: uri - externalUrl: + example: 더 신나게 + context: + $ref: "#/components/schemas/RecommendationContext" + createdAt: type: string - format: uri - platformUrls: - type: object - properties: - melon: - type: string - format: uri - youtubeMusic: - type: string - format: uri - isLiked: - type: boolean - isSaved: - type: boolean + format: date-time - FeaturedPlaylist: + RecommendationEventsRequest: type: object required: - - id - - regionName - - description - - trackCount - - durationText + - events properties: - id: - type: string - example: busan-ocean-walk - regionName: - type: string - example: 부산 - description: - type: string - example: 부산광역시에 어울리는 노래를 추천해드립니다. - trackCount: - type: integer - example: 12 - durationText: - type: string - example: 40:00분 - source: - type: string - enum: - - location - - trend - - personalized - example: location + events: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/RecommendationEvent" - MoodRecommendation: + RecapItem: type: object required: - id - title - - color - - track + - placeName + - representativeTrack + - createdAt properties: id: type: string - example: fresh-drive-pop + example: recap_001 title: type: string - example: Music Genre - subtitle: + example: 부산의 밤을 담은 사운드 + placeName: type: string - example: 드라이브에 어울리는 청량한 팝 - color: + example: 광안리해수욕장 + representativeTrack: + $ref: "#/components/schemas/Track" + createdAt: type: string - example: "#31206D" - genres: - type: array - items: - type: string - example: - - 팝 - - K-POP - moods: - type: array - items: - type: string - example: - - 청량한 - - 신나는 - travelStyles: + format: date-time + momentCount: + type: integer + example: 8 + sessionId: + type: string + + RecapTemplateId: + type: string + enum: + - album + - film + - lp + - video + + RecapCreateRequest: + type: object + required: + - templateId + properties: + sessionId: + type: string + momentLogIds: type: array items: type: string - example: - - 드라이브 - playlistId: + templateId: + $ref: "#/components/schemas/RecapTemplateId" + title: + type: string + representativeTrackId: type: string - description: 추천 카드 선택 시 바로 열 수 있는 플레이리스트 ID. 없으면 앱은 트랙 미리 재생으로 폴백합니다. - example: drive - track: - $ref: "#/components/schemas/Track" - PlaylistCuration: + RecapShareMoment: type: object required: - id - - regionName - - reason - - trackCount - - durationText - - tracks + - placeName + - trackTitle + - artistName + - recordedAt properties: id: type: string - example: busan-ocean-walk - regionName: + imageUrl: type: string - example: 부산 + format: uri placeName: type: string example: 광안리해수욕장 - reason: - type: string - example: 광안리 해변 산책과 어울리는 청량한 플레이리스트예요. - coverImageUrl: - type: string - format: uri - backgroundImageUrl: - type: string - format: uri - trackCount: - type: integer - example: 12 - durationText: + trackTitle: type: string - example: 40:00분 - tracks: - type: array - items: - $ref: "#/components/schemas/Track" - context: - $ref: "#/components/schemas/RecommendationContext" + example: Ocean Drive + artistName: + type: string + example: Soundlog Curator + recordedAt: + type: string + format: date-time - MusicLogItem: + RecapShare: type: object required: - id - placeName - trackTitle - artistName - - createdAt + - recordedAt properties: id: type: string - example: log_001 placeName: type: string - example: 광안리해수욕장 trackTitle: type: string - example: Ocean Drive artistName: type: string - example: Soundlog Curator - createdAt: + backgroundImageUrl: type: string - format: date-time - imageUrl: + format: uri + discImageUrl: type: string format: uri - recapShareId: + moments: + type: array + items: + $ref: "#/components/schemas/RecapShareMoment" + recordedAt: type: string - example: recap_001 + format: date-time + shareImageUrl: + type: string + format: uri - UserProfileInput: + RecapShareEventRequest: type: object required: - - locationRecommendationEnabled - - preferredGenres - - preferredMoods - - travelStyles + - type + - createdAt properties: - companionType: + type: type: string enum: - - solo - - friends - - couple - - family - example: friends - locationRecommendationEnabled: - type: boolean - example: true - preferredGenres: - type: array - items: - type: string - example: - - K-POP - - 인디 - preferredMoods: - type: array - items: - type: string - example: - - 청량한 - - 잔잔한 - travelStyles: - type: array - items: - type: string - example: - - 산책 - - 카페 투어 - dislikedArtists: - type: array - items: - type: string - birthYear: - type: integer - minimum: 1900 - maximum: 2026 - gender: + - save_image + - os_share + - instagram + - snapchat + - messages + createdAt: type: string - enum: - - female - - male - - non_binary - - undisclosed - - UserProfile: - allOf: - - $ref: "#/components/schemas/UserProfileInput" - - type: object - required: - - completedOnboarding - properties: - completedOnboarding: - type: boolean - example: true - updatedAt: - type: string - format: date-time + format: date-time - RecommendationContext: + TravelRoomCreateRequest: type: object properties: - moodFilter: + sessionId: type: string - example: 청량한 - recommendationMode: - $ref: "#/components/schemas/MusicRecommendationMode" - placeCategory: + title: type: string - example: 해변 - placeId: + minLength: 1 + maxLength: 80 + default: Soundlog 여행방 + visibility: type: string - example: "126078" - placeName: + enum: + - invite_only + - companions + default: invite_only + + TravelRoomJoinRequest: + type: object + properties: + displayName: type: string - example: 광안리해수욕장 - topFilter: + minLength: 1 + maxLength: 40 + example: 수경 + inviteCode: type: string - example: 전체 - travelMode: - $ref: "#/components/schemas/TravelMode" + minLength: 4 + maxLength: 16 + example: A1B2C3D4 - ContextualPlaylistRequest: + TravelRoomMomentCreateRequest: type: object properties: - location: - $ref: "#/components/schemas/GeoPoint" - placeId: + momentLogId: type: string - travelMode: - $ref: "#/components/schemas/TravelMode" - moodTags: - type: array - items: - $ref: "#/components/schemas/MoodTag" - preferredGenres: - type: array - items: - type: string - preferredMoods: - type: array - items: - type: string - excludeTrackIds: - type: array - items: - type: string + trackId: + type: string + example: seoul-night-track + trackTitle: + type: string + maxLength: 160 + example: 서울의 밤 + artistName: + type: string + maxLength: 120 + example: 10cm + placeName: + type: string + maxLength: 120 + example: 강릉 카페거리 + note: + type: string + maxLength: 240 + example: 주문진 바다 컷 + status: + type: string + enum: + - candidate + - accepted + default: candidate - LibraryTrackUpdateRequest: + TravelRoomRecapCreateRequest: type: object - required: - - action properties: - action: + representativeTrackId: + type: string + templateId: type: string enum: - - like - - unlike - - save - - unsave - playlistId: + - album + - film + - lp + default: album + title: type: string - context: - $ref: "#/components/schemas/RecommendationContext" + maxLength: 120 - LibraryTrackState: + TravelRoomMember: type: object required: - - trackId - - isLiked - - isSaved + - id + - userId + - role + - joinedAt properties: - trackId: + id: type: string - isLiked: - type: boolean - isSaved: - type: boolean - updatedAt: + userId: + type: string + role: + type: string + enum: + - owner + - member + displayName: + type: string + joinedAt: type: string format: date-time - LibraryTrackRecord: + TravelRoomMoment: type: object required: + - id + - userId + - status - createdAt - - track properties: - createdAt: + id: type: string - format: date-time - playlistId: + userId: type: string - kind: + momentLogId: + type: string + placeName: + type: string + note: + type: string + status: type: string enum: - - liked - - saved + - candidate + - accepted track: $ref: "#/components/schemas/Track" + createdAt: + type: string + format: date-time - MomentLogCreateForm: + TravelRoom: type: object required: - - photo + - id + - title + - inviteCode + - visibility + - memberCount + - momentCount + - members + - moments - createdAt - - moodTags + - updatedAt properties: - photo: + id: type: string - format: binary - description: 카메라 촬영 이미지 파일 - createdAt: + title: + type: string + inviteCode: type: string - format: date-time sessionId: type: string - lat: - type: number - format: double - lng: - type: number - format: double - placeId: + visibility: type: string - placeName: + enum: + - invite_only + - companions + memberCount: + type: integer + momentCount: + type: integer + members: + type: array + items: + $ref: "#/components/schemas/TravelRoomMember" + moments: + type: array + items: + $ref: "#/components/schemas/TravelRoomMoment" + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + SoundMapCurrentTrackRequest: + type: object + required: + - location + - visibility + properties: + location: + $ref: "#/components/schemas/GeoPoint" + sessionId: type: string - placeCategory: + visibility: type: string + enum: + - companions + - nearby + - private trackId: type: string trackTitle: type: string + maxLength: 160 artistName: type: string + maxLength: 120 travelMode: $ref: "#/components/schemas/TravelMode" moodTags: type: array items: $ref: "#/components/schemas/MoodTag" + placeName: + type: string + maxLength: 120 + ttlMinutes: + type: integer + minimum: 5 + maximum: 240 + default: 120 - MomentLog: + SoundMapPin: type: object required: - id - - photoUrl - - createdAt + - alias + - isMine + - visibility + - location - moodTags - - syncStatus + - profile + - expiresAt + - updatedAt properties: id: type: string - example: moment_001 - photoUrl: + userId: type: string - format: uri - createdAt: + description: 본인 핀일 때만 내려갑니다. + alias: type: string - format: date-time - sessionId: + example: 근처 여행자 + isMine: + type: boolean + visibility: type: string + enum: + - companions + - nearby + - private location: $ref: "#/components/schemas/GeoPoint" - placeCategory: - type: string - placeId: - type: string + moodTags: + type: array + items: + $ref: "#/components/schemas/MoodTag" placeName: type: string + profile: + type: object + required: + - preferredGenres + - preferredMoods + - travelStyles + properties: + preferredGenres: + type: array + items: + type: string + preferredMoods: + type: array + items: + type: string + travelStyles: + type: array + items: + type: string + sessionId: + type: string track: $ref: "#/components/schemas/Track" travelMode: $ref: "#/components/schemas/TravelMode" - moodTags: - type: array - items: - $ref: "#/components/schemas/MoodTag" - source: + expiresAt: type: string - enum: - - camera - syncStatus: + format: date-time + updatedAt: type: string - enum: - - pending - - synced - - failed - example: synced - - RecommendationEventType: - type: string - enum: - - track_external_open - - track_like - - track_unlike - - track_save - - track_unsave - - playlist_open - - mood_filter_change - - live_track_shared - - nearby_sound_opened - - recommendation_mode_change - - top_filter_change - - recap_representative_track_select + format: date-time - RecommendationEvent: + MusicMatch: type: object required: - id - - sessionId - - type - - context - - createdAt + - pin + - targetPinId + - matchScore + - safety properties: id: type: string - description: 클라이언트가 생성한 이벤트 ID - example: event_001 - sessionId: - type: string - example: session_001 - type: - $ref: "#/components/schemas/RecommendationEventType" - trackId: + pin: + $ref: "#/components/schemas/SoundMapPin" + targetPinId: type: string - playlistId: + matchScore: + type: integer + minimum: 0 + maximum: 100 + safety: + type: object + required: + - exactLocationHidden + - firstMessageTemplates + - contactHiddenUntilAccepted + properties: + exactLocationHidden: + type: boolean + firstMessageTemplates: + type: array + items: + type: string + enum: + - liked_track + - walk_together + - cafe_together + contactHiddenUntilAccepted: + type: boolean + + TravelMateRequestCreateRequest: + type: object + properties: + targetPinId: type: string - value: + targetUserId: type: string - example: 더 신나게 - context: - $ref: "#/components/schemas/RecommendationContext" - createdAt: + messageTemplate: type: string - format: date-time + enum: + - liked_track + - walk_together + - cafe_together + default: liked_track - RecommendationEventsRequest: + TravelMateRequestUpdateRequest: type: object required: - - events + - action properties: - events: - type: array - minItems: 1 - maxItems: 100 - items: - $ref: "#/components/schemas/RecommendationEvent" + action: + type: string + enum: + - accept + - decline + - cancel + - expire - RecapItem: + TravelMateRequest: type: object required: - id - - title - - placeName - - representativeTrack + - requesterId + - targetUserId + - messageTemplate + - status - createdAt + - updatedAt properties: id: type: string - example: recap_001 - title: + requesterId: type: string - example: 부산의 밤을 담은 사운드 - placeName: + targetUserId: type: string - example: 광안리해수욕장 - representativeTrack: - $ref: "#/components/schemas/Track" + targetPinId: + type: string + messageTemplate: + type: string + enum: + - liked_track + - walk_together + - cafe_together + status: + type: string + enum: + - pending + - accepted + - declined + - expired + - cancelled createdAt: type: string format: date-time - momentCount: - type: integer - example: 8 - sessionId: + updatedAt: type: string + format: date-time - RecapTemplateId: - type: string - enum: - - album - - film - - lp - - video - - RecapCreateRequest: + CommunityReportRequest: type: object required: - - templateId + - reason properties: - sessionId: + reason: type: string - momentLogIds: - type: array - items: - type: string - templateId: - $ref: "#/components/schemas/RecapTemplateId" - title: + enum: + - safety + - spam + - inappropriate + - other + targetUserId: type: string - representativeTrackId: + targetPinId: + type: string + requestId: type: string + details: + type: string + maxLength: 500 - RecapShareMoment: + TravelRoomResponse: type: object required: - - id - - placeName - - trackTitle - - artistName - - recordedAt + - data properties: - id: - type: string - imageUrl: - type: string - format: uri - placeName: - type: string - example: 광안리해수욕장 - trackTitle: - type: string - example: Ocean Drive - artistName: - type: string - example: Soundlog Curator - recordedAt: - type: string - format: date-time + data: + $ref: "#/components/schemas/TravelRoom" - RecapShare: + TravelRoomMomentResponse: type: object required: - - id - - placeName - - trackTitle - - artistName - - recordedAt + - data properties: - id: - type: string - placeName: - type: string - trackTitle: - type: string - artistName: - type: string - backgroundImageUrl: - type: string - format: uri - discImageUrl: - type: string - format: uri - moments: + data: + $ref: "#/components/schemas/TravelRoomMoment" + + SoundMapPinResponse: + type: object + required: + - data + properties: + data: + $ref: "#/components/schemas/SoundMapPin" + + SoundMapPinListResponse: + type: object + required: + - data + properties: + data: type: array items: - $ref: "#/components/schemas/RecapShareMoment" - recordedAt: - type: string - format: date-time - shareImageUrl: - type: string - format: uri + $ref: "#/components/schemas/SoundMapPin" - RecapShareEventRequest: + MusicMatchListResponse: type: object required: - - type - - createdAt + - data properties: - type: - type: string - enum: - - save_image - - os_share - - instagram - - snapchat - - messages - createdAt: - type: string - format: date-time + data: + type: array + items: + $ref: "#/components/schemas/MusicMatch" + + TravelMateRequestResponse: + type: object + required: + - data + properties: + data: + $ref: "#/components/schemas/TravelMateRequest" TravelSessionCreateRequest: type: object diff --git a/prisma/migrations/20260707000000_community_features/migration.sql b/prisma/migrations/20260707000000_community_features/migration.sql new file mode 100644 index 0000000..62acb39 --- /dev/null +++ b/prisma/migrations/20260707000000_community_features/migration.sql @@ -0,0 +1,111 @@ +CREATE TABLE "TravelRoom" ( + "id" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "inviteCode" TEXT NOT NULL, + "sessionId" TEXT, + "visibility" TEXT NOT NULL DEFAULT 'invite_only', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TravelRoom_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "TravelRoomMember" ( + "id" TEXT NOT NULL, + "roomId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "displayName" TEXT, + "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TravelRoomMember_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "TravelRoomMoment" ( + "id" TEXT NOT NULL, + "roomId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "momentLogId" TEXT, + "placeName" TEXT, + "note" TEXT, + "status" TEXT NOT NULL DEFAULT 'candidate', + "trackSnapshot" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TravelRoomMoment_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "SoundMapPin" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "sessionId" TEXT, + "visibility" TEXT NOT NULL, + "lat" DOUBLE PRECISION NOT NULL, + "lng" DOUBLE PRECISION NOT NULL, + "approxLat" DOUBLE PRECISION NOT NULL, + "approxLng" DOUBLE PRECISION NOT NULL, + "travelMode" TEXT, + "moodTags" TEXT[], + "placeName" TEXT, + "trackSnapshot" JSONB, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SoundMapPin_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "TravelMateRequest" ( + "id" TEXT NOT NULL, + "requesterId" TEXT NOT NULL, + "targetUserId" TEXT NOT NULL, + "targetPinId" TEXT, + "messageTemplate" TEXT NOT NULL, + "status" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TravelMateRequest_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "CommunityBlock" ( + "id" TEXT NOT NULL, + "blockerId" TEXT NOT NULL, + "blockedUserId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CommunityBlock_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "CommunityReport" ( + "id" TEXT NOT NULL, + "reporterId" TEXT NOT NULL, + "targetUserId" TEXT, + "targetPinId" TEXT, + "requestId" TEXT, + "reason" TEXT NOT NULL, + "details" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CommunityReport_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "TravelRoom_inviteCode_key" ON "TravelRoom"("inviteCode"); +CREATE UNIQUE INDEX "TravelRoomMember_roomId_userId_key" ON "TravelRoomMember"("roomId", "userId"); +CREATE UNIQUE INDEX "SoundMapPin_userId_key" ON "SoundMapPin"("userId"); +CREATE UNIQUE INDEX "CommunityBlock_blockerId_blockedUserId_key" ON "CommunityBlock"("blockerId", "blockedUserId"); + +ALTER TABLE "TravelRoom" ADD CONSTRAINT "TravelRoom_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TravelRoomMember" ADD CONSTRAINT "TravelRoomMember_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "TravelRoom"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TravelRoomMember" ADD CONSTRAINT "TravelRoomMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TravelRoomMoment" ADD CONSTRAINT "TravelRoomMoment_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "TravelRoom"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TravelRoomMoment" ADD CONSTRAINT "TravelRoomMoment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SoundMapPin" ADD CONSTRAINT "SoundMapPin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TravelMateRequest" ADD CONSTRAINT "TravelMateRequest_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TravelMateRequest" ADD CONSTRAINT "TravelMateRequest_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CommunityBlock" ADD CONSTRAINT "CommunityBlock_blockerId_fkey" FOREIGN KEY ("blockerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CommunityBlock" ADD CONSTRAINT "CommunityBlock_blockedUserId_fkey" FOREIGN KEY ("blockedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CommunityReport" ADD CONSTRAINT "CommunityReport_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CommunityReport" ADD CONSTRAINT "CommunityReport_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CommunityReport" ADD CONSTRAINT "CommunityReport_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "TravelMateRequest"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/models/auth.prisma b/prisma/models/auth.prisma index 672ae43..112db2a 100644 --- a/prisma/models/auth.prisma +++ b/prisma/models/auth.prisma @@ -16,6 +16,16 @@ model User { travelSessions TravelSession[] recapShareEvents RecapShareEvent[] idempotencyRecords IdempotencyRecord[] + ownedTravelRooms TravelRoom[] @relation("TravelRoomOwner") + travelRoomMembers TravelRoomMember[] + travelRoomMoments TravelRoomMoment[] + soundMapPins SoundMapPin[] + sentMateRequests TravelMateRequest[] @relation("TravelMateRequester") + receivedMateRequests TravelMateRequest[] @relation("TravelMateTarget") + communityBlocksMade CommunityBlock[] @relation("CommunityBlocker") + communityBlocksTaken CommunityBlock[] @relation("CommunityBlocked") + communityReports CommunityReport[] @relation("CommunityReporter") + communityReportsOn CommunityReport[] @relation("CommunityReportedUser") @@unique([provider, providerUserId]) } diff --git a/prisma/models/community.prisma b/prisma/models/community.prisma new file mode 100644 index 0000000..6757352 --- /dev/null +++ b/prisma/models/community.prisma @@ -0,0 +1,98 @@ +model TravelRoom { + id String @id + ownerId String + title String + inviteCode String @unique + sessionId String? + visibility String @default("invite_only") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + owner User @relation("TravelRoomOwner", fields: [ownerId], references: [id], onDelete: Cascade) + members TravelRoomMember[] + moments TravelRoomMoment[] +} + +model TravelRoomMember { + id String @id @default(cuid()) + roomId String + userId String + role String + displayName String? + joinedAt DateTime @default(now()) + room TravelRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([roomId, userId]) +} + +model TravelRoomMoment { + id String @id + roomId String + userId String + momentLogId String? + placeName String? + note String? + status String @default("candidate") + trackSnapshot Json? + createdAt DateTime @default(now()) + room TravelRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model SoundMapPin { + id String @id + userId String @unique + sessionId String? + visibility String + lat Float + lng Float + approxLat Float + approxLng Float + travelMode String? + moodTags String[] + placeName String? + trackSnapshot Json? + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model TravelMateRequest { + id String @id + requesterId String + targetUserId String + targetPinId String? + messageTemplate String + status String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requester User @relation("TravelMateRequester", fields: [requesterId], references: [id], onDelete: Cascade) + targetUser User @relation("TravelMateTarget", fields: [targetUserId], references: [id], onDelete: Cascade) + reports CommunityReport[] +} + +model CommunityBlock { + id String @id @default(cuid()) + blockerId String + blockedUserId String + createdAt DateTime @default(now()) + blocker User @relation("CommunityBlocker", fields: [blockerId], references: [id], onDelete: Cascade) + blockedUser User @relation("CommunityBlocked", fields: [blockedUserId], references: [id], onDelete: Cascade) + + @@unique([blockerId, blockedUserId]) +} + +model CommunityReport { + id String @id @default(cuid()) + reporterId String + targetUserId String? + targetPinId String? + requestId String? + reason String + details String? + createdAt DateTime @default(now()) + reporter User @relation("CommunityReporter", fields: [reporterId], references: [id], onDelete: Cascade) + targetUser User? @relation("CommunityReportedUser", fields: [targetUserId], references: [id], onDelete: SetNull) + mateRequest TravelMateRequest? @relation(fields: [requestId], references: [id], onDelete: SetNull) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index e41aaeb..8436d97 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -126,6 +126,14 @@ export async function seedPublicCatalog() { } export async function seedDatabase() { + await prisma.communityReport.deleteMany({}); + await prisma.communityBlock.deleteMany({}); + await prisma.travelMateRequest.deleteMany({}); + await prisma.soundMapPin.deleteMany({}); + await prisma.travelRoomMoment.deleteMany({}); + await prisma.travelRoomMember.deleteMany({}); + await prisma.travelRoom.deleteMany({}); + const user = await prisma.user.upsert({ where: { provider_providerUserId: defaultUser, diff --git a/src/constants/error.constants.ts b/src/constants/error.constants.ts index 1721bd0..a452ba3 100644 --- a/src/constants/error.constants.ts +++ b/src/constants/error.constants.ts @@ -27,5 +27,9 @@ export const ERROR_MESSAGES = { RESOURCE_NOT_FOUND: '요청한 리소스를 찾을 수 없습니다.', ROUTE_NOT_FOUND: '요청한 API 경로를 찾을 수 없습니다.', TRACK_NOT_FOUND: '트랙을 찾을 수 없습니다.', + TRAVEL_MATE_REQUEST_NOT_FOUND: '동행 매칭 요청을 찾을 수 없습니다.', + TRAVEL_MATE_TARGET_REQUIRED: '동행 매칭 대상이 필요합니다.', + TRAVEL_ROOM_INVITE_CODE_INVALID: '여행방 초대 코드가 올바르지 않습니다.', + TRAVEL_ROOM_NOT_FOUND: '여행방을 찾을 수 없습니다.', TRAVEL_SESSION_NOT_FOUND: '여행 세션을 찾을 수 없습니다.', } as const; diff --git a/src/controllers/community.controller.ts b/src/controllers/community.controller.ts new file mode 100644 index 0000000..5714de3 --- /dev/null +++ b/src/controllers/community.controller.ts @@ -0,0 +1,99 @@ +import type { Request, Response } from 'express'; + +import { requireUser } from '../middlewares/auth.middleware.js'; +import { apiService } from '../services/api.service.js'; +import { acceptedResponse, dataResponse } from '../utils/response.js'; + +export const communityController = { + async createTravelRoom(req: Request, res: Response) { + const user = requireUser(req); + res.status(201).json(dataResponse(await apiService.createTravelRoom(user.id, req.body))); + }, + + async getTravelRoom(req: Request, res: Response) { + const user = requireUser(req); + res.json(dataResponse(await apiService.getTravelRoom(user.id, String(req.params.roomId)))); + }, + + async joinTravelRoom(req: Request, res: Response) { + const user = requireUser(req); + res.json( + dataResponse( + await apiService.joinTravelRoom(user.id, String(req.params.roomId), req.body), + ), + ); + }, + + async addTravelRoomMoment(req: Request, res: Response) { + const user = requireUser(req); + res.status(201).json( + dataResponse( + await apiService.addTravelRoomMoment(user.id, String(req.params.roomId), req.body), + ), + ); + }, + + async createTravelRoomRecap(req: Request, res: Response) { + const user = requireUser(req); + res.status(201).json( + dataResponse( + await apiService.createTravelRoomRecap( + user.id, + String(req.params.roomId), + req.body, + req.header('Idempotency-Key'), + ), + ), + ); + }, + + async getSoundMap(req: Request, res: Response) { + const user = requireUser(req); + res.json(dataResponse(await apiService.getSoundMapPins(user.id, req.query))); + }, + + async upsertCurrentTrack(req: Request, res: Response) { + const user = requireUser(req); + res.status(202).json(dataResponse(await apiService.upsertSoundMapCurrentTrack(user.id, req.body))); + }, + + async getNearbySounds(req: Request, res: Response) { + const user = requireUser(req); + res.json(dataResponse(await apiService.getNearbySoundMatches(user.id, req.query))); + }, + + async getMusicMatches(req: Request, res: Response) { + const user = requireUser(req); + res.json(dataResponse(await apiService.getMusicMatches(user.id, req.query))); + }, + + async createTravelMateRequest(req: Request, res: Response) { + const user = requireUser(req); + res.status(201).json(dataResponse(await apiService.createTravelMateRequest(user.id, req.body))); + }, + + async updateTravelMateRequest(req: Request, res: Response) { + const user = requireUser(req); + res.json( + dataResponse( + await apiService.updateTravelMateRequest( + user.id, + String(req.params.requestId), + req.body, + ), + ), + ); + }, + + async blockCommunityUser(req: Request, res: Response) { + const user = requireUser(req); + await apiService.blockCommunityUser(user.id, req.body); + res.status(202).json(acceptedResponse()); + }, + + async reportCommunityTarget(req: Request, res: Response) { + const user = requireUser(req); + await apiService.reportCommunityTarget(user.id, req.body); + res.status(202).json(acceptedResponse()); + }, +}; diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 2754a4f..fdc24b4 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,4 +1,5 @@ export { authController } from './auth.controller.js'; +export { communityController } from './community.controller.js'; export { devDbTestController } from './dev-db-test.controller.js'; export { homeController } from './home.controller.js'; export { libraryController } from './library.controller.js'; diff --git a/src/mock/mock-db.ts b/src/mock/mock-db.ts index 368f81a..ff8559e 100644 --- a/src/mock/mock-db.ts +++ b/src/mock/mock-db.ts @@ -83,6 +83,67 @@ type MockPasswordUser = { passwordHash: string; }; +type MockTravelRoom = { + createdAt: Date; + id: string; + inviteCode: string; + ownerId: string; + sessionId?: string; + title: string; + updatedAt: Date; + visibility: string; +}; + +type MockTravelRoomMember = { + displayName?: string; + id: string; + joinedAt: Date; + role: string; + roomId: string; + userId: string; +}; + +type MockTravelRoomMoment = { + createdAt: Date; + id: string; + momentLogId?: string; + note?: string; + placeName?: string; + roomId: string; + status: string; + trackSnapshot?: MockTrack; + userId: string; +}; + +type MockSoundMapPin = { + approxLat: number; + approxLng: number; + createdAt: Date; + expiresAt: Date; + id: string; + lat: number; + lng: number; + moodTags: string[]; + placeName?: string; + sessionId?: string; + trackSnapshot?: MockTrack; + travelMode?: string; + updatedAt: Date; + userId: string; + visibility: string; +}; + +type MockTravelMateRequest = { + createdAt: Date; + id: string; + messageTemplate: string; + requesterId: string; + status: string; + targetPinId?: string; + targetUserId: string; + updatedAt: Date; +}; + function cloneTrack(track: (typeof tracks)[number]): MockTrack { return { id: track.id, @@ -202,6 +263,27 @@ function createMockDb() { })), refreshTokens: [] as MockRefreshToken[], passwordUsers: [] as MockPasswordUser[], + travelRooms: [] as MockTravelRoom[], + travelRoomMembers: [] as MockTravelRoomMember[], + travelRoomMoments: [] as MockTravelRoomMoment[], + soundMapPins: [] as MockSoundMapPin[], + travelMateRequests: [] as MockTravelMateRequest[], + communityBlocks: [] as Array<{ + blockedUserId: string; + blockerId: string; + createdAt: Date; + id: string; + }>, + communityReports: [] as Array<{ + createdAt: Date; + details?: string; + id: string; + reason: string; + reporterId: string; + requestId?: string; + targetPinId?: string; + targetUserId?: string; + }>, idempotencyRecords: [] as Array<{ idempotencyKey: string; response: unknown; diff --git a/src/routes/index.ts b/src/routes/index.ts index 717a14b..153627a 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { authController, + communityController, devDbTestController, homeController, libraryController, @@ -21,6 +22,7 @@ import { momentPhotoUpload } from '../middlewares/upload.middleware.js'; import { validate } from '../middlewares/validate.middleware.js'; import { authValidators, + communityValidators, devDbTestValidators, homeValidators, libraryValidators, @@ -186,6 +188,98 @@ export function createApiRouter() { asyncHandler(recapController.createShareEvent), ); + router.post( + '/v1/travel-rooms', + authMiddleware, + validate({ body: communityValidators.createRoomBody }), + asyncHandler(communityController.createTravelRoom), + ); + router.get( + '/v1/travel-rooms/:roomId', + authMiddleware, + validate({ params: communityValidators.roomParams }), + asyncHandler(communityController.getTravelRoom), + ); + router.post( + '/v1/travel-rooms/:roomId/join', + authMiddleware, + validate({ + params: communityValidators.roomParams, + body: communityValidators.joinRoomBody, + }), + asyncHandler(communityController.joinTravelRoom), + ); + router.post( + '/v1/travel-rooms/:roomId/moments', + authMiddleware, + validate({ + params: communityValidators.roomParams, + body: communityValidators.addRoomMomentBody, + }), + asyncHandler(communityController.addTravelRoomMoment), + ); + router.post( + '/v1/travel-rooms/:roomId/recaps', + authMiddleware, + validate({ + params: communityValidators.roomParams, + body: communityValidators.createRoomRecapBody, + }), + asyncHandler(communityController.createTravelRoomRecap), + ); + + router.get( + '/v1/sound-map', + authMiddleware, + validate({ query: communityValidators.soundMapQuery }), + asyncHandler(communityController.getSoundMap), + ); + router.post( + '/v1/sound-map/current-track', + authMiddleware, + validate({ body: communityValidators.currentTrackBody }), + asyncHandler(communityController.upsertCurrentTrack), + ); + router.get( + '/v1/sound-map/nearby', + authMiddleware, + validate({ query: communityValidators.musicMatchesQuery }), + asyncHandler(communityController.getNearbySounds), + ); + router.get( + '/v1/music-matches', + authMiddleware, + validate({ query: communityValidators.musicMatchesQuery }), + asyncHandler(communityController.getMusicMatches), + ); + router.post( + '/v1/travel-mate-requests', + authMiddleware, + validate({ body: communityValidators.createMateRequestBody }), + asyncHandler(communityController.createTravelMateRequest), + ); + router.patch( + '/v1/travel-mate-requests/:requestId', + authMiddleware, + validate({ + params: communityValidators.mateRequestParams, + body: communityValidators.updateMateRequestBody, + }), + asyncHandler(communityController.updateTravelMateRequest), + ); + router.post( + '/v1/community/blocks', + authMiddleware, + validate({ body: communityValidators.blockBody }), + asyncHandler(communityController.blockCommunityUser), + ); + router.post( + '/v1/community/reports', + authMiddleware, + validate({ body: communityValidators.reportBody }), + asyncHandler(communityController.reportCommunityTarget), + ); + router.post( '/v1/travel-sessions', authMiddleware, diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index 0bfa0ed..ab137ee 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -167,6 +167,137 @@ function recapShareToDto(recap: (typeof mockDb.recaps)[number]) { }); } +function normalizeApproxCoordinate(value: number) { + return Math.round(value * 100) / 100; +} + +function distanceMeters(from: { lat: number; lng: number }, to: { lat: number; lng: number }) { + const earthRadiusMeters = 6_371_000; + const toRadians = (value: number) => (value * Math.PI) / 180; + const deltaLat = toRadians(to.lat - from.lat); + const deltaLng = toRadians(to.lng - from.lng); + const fromLat = toRadians(from.lat); + const toLat = toRadians(to.lat); + const haversine = + Math.sin(deltaLat / 2) ** 2 + + Math.cos(fromLat) * Math.cos(toLat) * Math.sin(deltaLng / 2) ** 2; + + return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); +} + +function filterPinsByRadius( + pins: T[], + query: { lat?: number; lng?: number; radiusMeters?: number }, +) { + if (query.lat === undefined || query.lng === undefined) { + return pins; + } + + const radiusMeters = query.radiusMeters ?? 3000; + return pins.filter( + (pin) => distanceMeters({ lat: query.lat!, lng: query.lng! }, pin) <= radiusMeters, + ); +} + +function createInviteCode() { + return Math.random().toString(36).slice(2, 8).toUpperCase(); +} + +function getMockUserProfile(userId: string) { + const passwordUser = mockDb.passwordUsers.find((user) => user.id === userId); + return { + displayName: passwordUser?.displayName ?? mockDb.user.displayName, + preferredGenres: mockDb.profile.preferredGenres, + preferredMoods: mockDb.profile.preferredMoods, + travelStyles: mockDb.profile.travelStyles, + }; +} + +function roomToDto(room: (typeof mockDb.travelRooms)[number]) { + const members = mockDb.travelRoomMembers.filter((member) => member.roomId === room.id); + const moments = mockDb.travelRoomMoments + .filter((moment) => moment.roomId === room.id) + .sort((first, second) => second.createdAt.getTime() - first.createdAt.getTime()); + + return { + id: room.id, + title: room.title, + inviteCode: room.inviteCode, + sessionId: room.sessionId, + visibility: room.visibility, + memberCount: members.length, + momentCount: moments.length, + members: members.map((member) => ({ + id: member.id, + userId: member.userId, + role: member.role, + displayName: member.displayName, + joinedAt: member.joinedAt.toISOString(), + })), + moments: moments.map((moment) => ({ + id: moment.id, + userId: moment.userId, + momentLogId: moment.momentLogId, + placeName: moment.placeName, + note: moment.note, + status: moment.status, + track: moment.trackSnapshot, + createdAt: moment.createdAt.toISOString(), + })), + createdAt: room.createdAt.toISOString(), + updatedAt: room.updatedAt.toISOString(), + }; +} + +function soundMapPinToDto(pin: (typeof mockDb.soundMapPins)[number], viewerId: string) { + const isMine = pin.userId === viewerId; + const profile = getMockUserProfile(pin.userId); + + return { + id: pin.id, + alias: isMine ? '나' : profile.displayName ?? '근처 여행자', + isMine, + visibility: pin.visibility, + location: isMine || pin.visibility !== 'nearby' + ? { lat: pin.lat, lng: pin.lng } + : { lat: pin.approxLat, lng: pin.approxLng }, + moodTags: pin.moodTags, + placeName: pin.placeName, + profile: { + preferredGenres: profile.preferredGenres, + preferredMoods: profile.preferredMoods, + travelStyles: profile.travelStyles, + }, + sessionId: pin.sessionId, + track: pin.trackSnapshot, + travelMode: pin.travelMode, + expiresAt: pin.expiresAt.toISOString(), + updatedAt: pin.updatedAt.toISOString(), + }; +} + +function scoreMockMatch(pin: (typeof mockDb.soundMapPins)[number], params: { mood?: string; state?: string }) { + let score = 70; + if (params.mood && mockDb.profile.preferredMoods.some((mood) => params.mood?.includes(mood))) { + score += 8; + } + if (params.state && mockDb.profile.travelStyles.some((style) => params.state?.includes(style))) { + score += 8; + } + if (pin.trackSnapshot) { + score += 6; + } + return Math.min(score, 96); +} + +function mateRequestToDto(request: (typeof mockDb.travelMateRequests)[number]) { + return { + ...request, + createdAt: request.createdAt.toISOString(), + updatedAt: request.updatedAt.toISOString(), + }; +} + function getDefaultPlaylistId(params?: { lat?: number; placeId?: string }) { if (params?.placeId) { const place = mockDb.places.find( @@ -607,6 +738,382 @@ export const mockSoundlogService = { }); }, + async createTravelRoom(userId: string, input: { + sessionId?: string; + title: string; + visibility: string; + }) { + const now = new Date(); + const room = { + id: createPublicId('room'), + inviteCode: createInviteCode(), + ownerId: userId, + sessionId: input.sessionId, + title: input.title, + visibility: input.visibility, + createdAt: now, + updatedAt: now, + }; + mockDb.travelRooms.push(room); + mockDb.travelRoomMembers.push({ + id: createPublicId('member'), + joinedAt: now, + role: 'owner', + roomId: room.id, + userId, + }); + + return roomToDto(room); + }, + + async getTravelRoom(userId: string, roomId: string) { + const room = mockDb.travelRooms.find((item) => item.id === roomId); + const isMember = mockDb.travelRoomMembers.some( + (member) => member.roomId === roomId && member.userId === userId, + ); + + if (!room || !isMember) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + return roomToDto(room); + }, + + async joinTravelRoom(userId: string, roomId: string, input: { + displayName?: string; + inviteCode?: string; + }) { + const room = mockDb.travelRooms.find((item) => item.id === roomId); + + if (!room) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + if (input.inviteCode && input.inviteCode !== room.inviteCode) { + throw badRequest(ERROR_MESSAGES.TRAVEL_ROOM_INVITE_CODE_INVALID); + } + + const existing = mockDb.travelRoomMembers.find( + (member) => member.roomId === roomId && member.userId === userId, + ); + + if (existing) { + existing.displayName = input.displayName; + } else { + mockDb.travelRoomMembers.push({ + id: createPublicId('member'), + displayName: input.displayName, + joinedAt: new Date(), + role: 'member', + roomId, + userId, + }); + } + + return roomToDto(room); + }, + + async addTravelRoomMoment(userId: string, roomId: string, input: { + artistName?: string; + momentLogId?: string; + note?: string; + placeName?: string; + status?: string; + trackId?: string; + trackTitle?: string; + }) { + const isMember = mockDb.travelRoomMembers.some( + (member) => member.roomId === roomId && member.userId === userId, + ); + + if (!isMember) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + const momentLog = input.momentLogId + ? mockDb.momentLogs.find((moment) => moment.id === input.momentLogId) + : undefined; + const track = findMockTrack(input.trackId) ?? + momentLog?.trackSnapshot ?? { + id: input.trackId ?? createPublicId('track'), + title: input.trackTitle ?? '선택한 음악', + artist: input.artistName ?? '아티스트 미상', + }; + const moment = { + id: createPublicId('room_moment'), + createdAt: new Date(), + momentLogId: input.momentLogId, + note: input.note, + placeName: input.placeName ?? momentLog?.placeName, + roomId, + status: input.status ?? 'candidate', + trackSnapshot: track, + userId, + }; + mockDb.travelRoomMoments.push(moment); + + return { + id: moment.id, + userId: moment.userId, + momentLogId: moment.momentLogId, + note: moment.note, + placeName: moment.placeName, + status: moment.status, + track: moment.trackSnapshot, + createdAt: moment.createdAt.toISOString(), + }; + }, + + async createTravelRoomRecap(userId: string, roomId: string, input: { + representativeTrackId?: string; + templateId?: string; + title?: string; + }, idempotencyKey?: string) { + return withMockIdempotency( + { idempotencyKey, scope: `travel-room-recap.create.${roomId}`, userId }, + () => { + const room = mockDb.travelRooms.find((item) => item.id === roomId && item.ownerId === userId); + if (!room) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + const moments = mockDb.travelRoomMoments.filter((moment) => moment.roomId === roomId); + const representativeTrackId = + input.representativeTrackId ?? + moments.find((moment) => moment.trackSnapshot?.id)?.trackSnapshot?.id ?? + 'seoul-city'; + + if (!findMockTrack(representativeTrackId)) { + throw notFound(ERROR_MESSAGES.REPRESENTATIVE_TRACK_NOT_FOUND); + } + + const recap = { + id: createPublicId('recap'), + title: input.title ?? `${room.title} 공동 Recap`, + placeName: moments[0]?.placeName ?? room.title, + representativeTrackId, + createdAt: new Date(), + momentCount: moments.length, + sessionId: room.sessionId, + recordedAt: moments[0]?.createdAt ?? new Date(), + moments: moments.map((moment) => ({ + id: moment.id, + placeName: moment.placeName ?? '위치 없음', + trackTitle: moment.trackSnapshot?.title ?? '저장된 순간', + artistName: moment.trackSnapshot?.artist ?? '음악 없음', + recordedAt: moment.createdAt.toISOString(), + })), + }; + mockDb.recaps.unshift(recap); + + return compact({ + ...recapItemToDto(recap), + roomId, + templateId: input.templateId, + }); + }, + ); + }, + + async upsertSoundMapCurrentTrack(userId: string, input: { + artistName?: string; + location: { lat: number; lng: number }; + moodTags?: string[]; + placeName?: string; + sessionId?: string; + trackId?: string; + trackTitle?: string; + travelMode?: string; + ttlMinutes?: number; + visibility: string; + }) { + const now = new Date(); + const track = findMockTrack(input.trackId) ?? { + id: input.trackId ?? createPublicId('track'), + title: input.trackTitle ?? '선택한 음악', + artist: input.artistName ?? '아티스트 미상', + }; + const pin = mockDb.soundMapPins.find((item) => item.userId === userId); + const nextPin = { + id: pin?.id ?? createPublicId('sound_pin'), + userId, + sessionId: input.sessionId, + visibility: input.visibility, + lat: input.location.lat, + lng: input.location.lng, + approxLat: normalizeApproxCoordinate(input.location.lat), + approxLng: normalizeApproxCoordinate(input.location.lng), + travelMode: input.travelMode, + moodTags: input.moodTags ?? [], + placeName: input.placeName, + trackSnapshot: track, + expiresAt: new Date(Date.now() + (input.ttlMinutes ?? 120) * 60_000), + createdAt: pin?.createdAt ?? now, + updatedAt: now, + }; + + if (pin) { + Object.assign(pin, nextPin); + } else { + mockDb.soundMapPins.push(nextPin); + } + + return soundMapPinToDto(nextPin, userId); + }, + + async getSoundMapPins(userId: string, query: { + lat?: number; + lng?: number; + radiusMeters?: number; + visibility?: string; + }) { + const blockedIds = mockDb.communityBlocks + .filter((block) => block.blockerId === userId) + .map((block) => block.blockedUserId); + 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')); + + return filterPinsByRadius(pins, query) + .map((pin) => soundMapPinToDto(pin, userId)); + }, + + async getNearbySoundMatches(userId: string, query: { + lat?: number; + lng?: number; + mood?: string; + radiusMeters?: number; + state?: string; + }) { + const blockedIds = mockDb.communityBlocks + .filter((block) => block.blockerId === userId) + .map((block) => block.blockedUserId); + const pins = mockDb.soundMapPins + .filter((pin) => pin.expiresAt > new Date()) + .filter((pin) => pin.userId !== userId && pin.visibility === 'nearby') + .filter((pin) => !blockedIds.includes(pin.userId)); + + return filterPinsByRadius(pins, query) + .map((pin) => ({ + ...soundMapPinToDto(pin, userId), + matchScore: scoreMockMatch(pin, query), + targetPinId: pin.id, + })); + }, + + async getMusicMatches(userId: string, query: { mood?: string; state?: string }) { + const pins = await this.getNearbySoundMatches(userId, query); + return pins.map((pin) => ({ + id: `match-${pin.id}`, + pin, + targetPinId: pin.targetPinId, + matchScore: pin.matchScore, + safety: { + exactLocationHidden: true, + firstMessageTemplates: ['liked_track', 'walk_together', 'cafe_together'], + contactHiddenUntilAccepted: true, + }, + })); + }, + + async createTravelMateRequest(userId: string, input: { + messageTemplate: string; + targetPinId?: string; + targetUserId?: string; + }) { + const targetPin = input.targetPinId + ? mockDb.soundMapPins.find((pin) => pin.id === input.targetPinId) + : undefined; + const targetUserId = input.targetUserId ?? targetPin?.userId; + if (!targetUserId) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); + } + 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]; + + if (existingActiveRequest) { + return mateRequestToDto(existingActiveRequest); + } + + const now = new Date(); + const request = { + id: createPublicId('mate'), + requesterId: userId, + targetUserId, + targetPinId: input.targetPinId, + messageTemplate: input.messageTemplate, + status: 'pending', + createdAt: now, + updatedAt: now, + }; + mockDb.travelMateRequests.push(request); + return mateRequestToDto(request); + }, + + async updateTravelMateRequest(userId: string, requestId: string, input: { action: string }) { + const request = mockDb.travelMateRequests.find( + (item) => item.id === requestId && (item.requesterId === userId || item.targetUserId === userId), + ); + if (!request) { + throw notFound(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_NOT_FOUND); + } + const nextStatusByAction: Record = { + accept: 'accepted', + cancel: 'cancelled', + decline: 'declined', + expire: 'expired', + }; + request.status = nextStatusByAction[input.action] ?? request.status; + request.updatedAt = new Date(); + return mateRequestToDto(request); + }, + + async blockCommunityUser(userId: string, input: { targetPinId?: string; targetUserId?: string }) { + const targetPin = input.targetPinId + ? mockDb.soundMapPins.find((pin) => pin.id === input.targetPinId) + : undefined; + const targetUserId = input.targetUserId ?? targetPin?.userId; + + if (!targetUserId) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); + } + + if (!mockDb.communityBlocks.some((block) => block.blockerId === userId && block.blockedUserId === targetUserId)) { + mockDb.communityBlocks.push({ + id: createPublicId('block'), + blockedUserId: targetUserId, + blockerId: userId, + createdAt: new Date(), + }); + } + }, + + async reportCommunityTarget(userId: string, input: { + details?: string; + reason: string; + requestId?: string; + targetPinId?: string; + targetUserId?: string; + }) { + mockDb.communityReports.push({ + id: createPublicId('report'), + reporterId: userId, + reason: input.reason, + details: input.details, + requestId: input.requestId, + targetPinId: input.targetPinId, + targetUserId: input.targetUserId, + createdAt: new Date(), + }); + }, + async getRecaps(_userId: string, params: { cursor?: string; limit?: number }) { const recaps = [...mockDb.recaps].sort( (first, second) => second.createdAt.getTime() - first.createdAt.getTime(), diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index 53d64c5..5aed3b7 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -8,7 +8,12 @@ import { type PlaylistTrack, type Recap, type RegionSoundTrend, + type SoundMapPin, type Track, + type TravelMateRequest, + type TravelRoom, + type TravelRoomMember, + type TravelRoomMoment, type TravelSession, type UserProfile, } from '@prisma/client'; @@ -36,6 +41,21 @@ type TrackDto = { }; type RecommendationContext = Record; +type CommunityVisibility = 'companions' | 'nearby' | 'private'; +type RoomWithCommunity = TravelRoom & { + members: TravelRoomMember[]; + moments: TravelRoomMoment[]; +}; +type SoundMapPinWithUser = SoundMapPin & { + user: { + displayName: string | null; + profile: { + preferredGenres: string[]; + preferredMoods: string[]; + travelStyles: string[]; + } | null; + }; +}; type TourApiResponse = { response?: { @@ -79,6 +99,157 @@ function toInputJson(value: unknown): Prisma.InputJsonValue { return JSON.parse(JSON.stringify(value ?? { accepted: true })) as Prisma.InputJsonValue; } +function normalizeApproxCoordinate(value: number) { + return Math.round(value * 100) / 100; +} + +function distanceMeters(from: { lat: number; lng: number }, to: { lat: number; lng: number }) { + const earthRadiusMeters = 6_371_000; + const toRadians = (value: number) => (value * Math.PI) / 180; + const deltaLat = toRadians(to.lat - from.lat); + const deltaLng = toRadians(to.lng - from.lng); + const fromLat = toRadians(from.lat); + const toLat = toRadians(to.lat); + const haversine = + Math.sin(deltaLat / 2) ** 2 + + Math.cos(fromLat) * Math.cos(toLat) * Math.sin(deltaLng / 2) ** 2; + + return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); +} + +function filterPinsByRadius( + pins: T[], + query: { lat?: number; lng?: number; radiusMeters?: number }, +) { + if (query.lat === undefined || query.lng === undefined) { + return pins; + } + + const radiusMeters = query.radiusMeters ?? 3000; + return pins.filter( + (pin) => distanceMeters({ lat: query.lat!, lng: query.lng! }, pin) <= radiusMeters, + ); +} + +function createInviteCode() { + return crypto.randomBytes(4).toString('hex').toUpperCase(); +} + +function createTrackSnapshot(track?: Track | null, fallback?: { + artistName?: string; + trackId?: string; + trackTitle?: string; +}) { + if (track) { + return { + id: track.id, + title: track.title, + artist: track.artist, + fallbackColor: track.fallbackColor, + platformUrls: track.platformUrls, + }; + } + + if (!fallback?.trackId && !fallback?.trackTitle) { + return undefined; + } + + return { + id: fallback.trackId ?? createPublicId('track'), + title: fallback.trackTitle ?? '선택한 음악', + artist: fallback.artistName ?? '아티스트 미상', + }; +} + +function roomToDto(room: RoomWithCommunity) { + return { + id: room.id, + title: room.title, + inviteCode: room.inviteCode, + sessionId: room.sessionId ?? undefined, + visibility: room.visibility, + memberCount: room.members.length, + momentCount: room.moments.length, + members: room.members.map((member) => ({ + id: member.id, + userId: member.userId, + role: member.role, + displayName: member.displayName ?? undefined, + joinedAt: member.joinedAt.toISOString(), + })), + moments: room.moments.map((moment) => ({ + id: moment.id, + userId: moment.userId, + momentLogId: moment.momentLogId ?? undefined, + placeName: moment.placeName ?? undefined, + note: moment.note ?? undefined, + status: moment.status, + track: (moment.trackSnapshot as TrackDto | null) ?? undefined, + createdAt: moment.createdAt.toISOString(), + })), + createdAt: room.createdAt.toISOString(), + updatedAt: room.updatedAt.toISOString(), + }; +} + +function soundMapPinToDto(pin: SoundMapPinWithUser, viewerId: string, includeExactLocation = false) { + const isMine = pin.userId === viewerId; + const track = (pin.trackSnapshot as TrackDto | null) ?? undefined; + return { + id: pin.id, + userId: isMine ? pin.userId : undefined, + alias: isMine ? '나' : pin.user.displayName ?? '근처 여행자', + isMine, + visibility: pin.visibility, + location: includeExactLocation || isMine + ? { lat: pin.lat, lng: pin.lng } + : { lat: pin.approxLat, lng: pin.approxLng }, + moodTags: pin.moodTags, + placeName: pin.placeName ?? undefined, + profile: { + preferredGenres: pin.user.profile?.preferredGenres ?? [], + preferredMoods: pin.user.profile?.preferredMoods ?? [], + travelStyles: pin.user.profile?.travelStyles ?? [], + }, + sessionId: pin.sessionId ?? undefined, + track, + travelMode: pin.travelMode ?? undefined, + expiresAt: pin.expiresAt.toISOString(), + updatedAt: pin.updatedAt.toISOString(), + }; +} + +function scoreMatch(pin: SoundMapPinWithUser, params: { mood?: string; state?: string }) { + const profile = pin.user.profile; + let score = 64; + if (params.mood && profile?.preferredMoods.some((mood) => params.mood?.includes(mood))) { + score += 10; + } + if (params.state && profile?.travelStyles.some((style) => params.state?.includes(style))) { + score += 8; + } + if (pin.moodTags.length > 0) { + score += 6; + } + if (pin.trackSnapshot) { + score += 6; + } + return Math.min(score, 96); +} + +function mateRequestToDto(request: TravelMateRequest) { + return { + id: request.id, + requesterId: request.requesterId, + targetUserId: request.targetUserId, + targetPinId: request.targetPinId ?? undefined, + messageTemplate: request.messageTemplate, + status: request.status, + createdAt: request.createdAt.toISOString(), + updatedAt: request.updatedAt.toISOString(), + }; +} + function wait(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -1198,6 +1369,512 @@ export const soundlogService = { ); }, + async createTravelRoom(userId: string, input: { + sessionId?: string; + title: string; + visibility: string; + }) { + const room = await prisma.travelRoom.create({ + data: { + id: createPublicId('room'), + ownerId: userId, + inviteCode: createInviteCode(), + sessionId: input.sessionId, + title: input.title, + visibility: input.visibility, + members: { + create: { + userId, + role: 'owner', + }, + }, + }, + include: { + members: true, + moments: { orderBy: { createdAt: 'desc' } }, + }, + }); + + return roomToDto(room); + }, + + async getTravelRoom(userId: string, roomId: string) { + const room = await prisma.travelRoom.findFirst({ + where: { + id: roomId, + members: { some: { userId } }, + }, + include: { + members: true, + moments: { orderBy: { createdAt: 'desc' } }, + }, + }); + + if (!room) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + return roomToDto(room); + }, + + async joinTravelRoom(userId: string, roomId: string, input: { + displayName?: string; + inviteCode?: string; + }) { + const room = await prisma.travelRoom.findUnique({ + where: { id: roomId }, + include: { + members: true, + moments: { orderBy: { createdAt: 'desc' } }, + }, + }); + + if (!room) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + if (input.inviteCode && input.inviteCode !== room.inviteCode) { + throw badRequest(ERROR_MESSAGES.TRAVEL_ROOM_INVITE_CODE_INVALID); + } + + await prisma.travelRoomMember.upsert({ + where: { + roomId_userId: { + roomId, + userId, + }, + }, + update: { + displayName: input.displayName, + }, + create: { + roomId, + userId, + displayName: input.displayName, + role: 'member', + }, + }); + + return this.getTravelRoom(userId, roomId); + }, + + async addTravelRoomMoment(userId: string, roomId: string, input: { + artistName?: string; + momentLogId?: string; + note?: string; + placeName?: string; + status?: string; + trackId?: string; + trackTitle?: string; + }) { + const member = await prisma.travelRoomMember.findUnique({ + where: { + roomId_userId: { + roomId, + userId, + }, + }, + }); + + if (!member) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + const momentLog = input.momentLogId + ? await prisma.momentLog.findFirst({ where: { id: input.momentLogId, userId } }) + : undefined; + const track = input.trackId ? await prisma.track.findUnique({ where: { id: input.trackId } }) : undefined; + const trackSnapshot = + (momentLog?.trackSnapshot as TrackDto | null | undefined) ?? + createTrackSnapshot(track, { + artistName: input.artistName, + trackId: input.trackId, + trackTitle: input.trackTitle, + }); + + const roomMoment = await prisma.travelRoomMoment.create({ + data: { + id: createPublicId('room_moment'), + roomId, + userId, + momentLogId: momentLog?.id ?? input.momentLogId, + note: input.note, + placeName: input.placeName ?? momentLog?.placeName, + status: input.status ?? 'candidate', + trackSnapshot: trackSnapshot ? toInputJson(trackSnapshot) : undefined, + }, + }); + + return { + id: roomMoment.id, + userId: roomMoment.userId, + momentLogId: roomMoment.momentLogId ?? undefined, + note: roomMoment.note ?? undefined, + placeName: roomMoment.placeName ?? undefined, + status: roomMoment.status, + track: (roomMoment.trackSnapshot as TrackDto | null) ?? undefined, + createdAt: roomMoment.createdAt.toISOString(), + }; + }, + + async createTravelRoomRecap( + userId: string, + roomId: string, + input: { + representativeTrackId?: string; + templateId?: string; + title?: string; + }, + idempotencyKey?: string, + ) { + return withIdempotency( + { idempotencyKey, scope: `travel-room-recap.create.${roomId}`, userId }, + async () => { + const room = await prisma.travelRoom.findFirst({ + where: { + id: roomId, + ownerId: userId, + }, + include: { + moments: { orderBy: { createdAt: 'asc' } }, + }, + }); + + if (!room) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + const momentTracks = room.moments + .map((moment) => (moment.trackSnapshot as TrackDto | null)?.id) + .filter((trackId): trackId is string => Boolean(trackId)); + const representativeTrackId = input.representativeTrackId ?? momentTracks[0] ?? 'seoul-city'; + const track = await prisma.track.findUnique({ where: { id: representativeTrackId } }); + + if (!track) { + throw notFound(ERROR_MESSAGES.REPRESENTATIVE_TRACK_NOT_FOUND); + } + + const recap = await prisma.recap.create({ + data: { + id: createPublicId('recap'), + userId, + title: input.title ?? `${room.title} 공동 Recap`, + placeName: room.moments[0]?.placeName ?? room.title, + representativeTrackId: track.id, + momentCount: room.moments.length, + sessionId: room.sessionId, + recordedAt: room.moments[0]?.createdAt ?? new Date(), + moments: room.moments.map((moment) => { + const momentTrack = (moment.trackSnapshot as TrackDto | null) ?? undefined; + return { + id: moment.id, + placeName: moment.placeName ?? '위치 없음', + trackTitle: momentTrack?.title ?? '저장된 순간', + artistName: momentTrack?.artist ?? '음악 없음', + recordedAt: moment.createdAt.toISOString(), + }; + }) as Prisma.JsonArray, + }, + include: { representativeTrack: true }, + }); + + return compact({ + ...recapItemToDto(recap), + roomId, + templateId: input.templateId, + }); + }, + ); + }, + + async upsertSoundMapCurrentTrack(userId: string, input: { + artistName?: string; + location: { lat: number; lng: number }; + moodTags?: string[]; + placeName?: string; + sessionId?: string; + trackId?: string; + trackTitle?: string; + travelMode?: string; + ttlMinutes?: number; + visibility: CommunityVisibility; + }) { + 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, + }); + const expiresAt = new Date(Date.now() + (input.ttlMinutes ?? 120) * 60_000); + const pin = await prisma.soundMapPin.upsert({ + where: { userId }, + update: { + approxLat: normalizeApproxCoordinate(input.location.lat), + approxLng: normalizeApproxCoordinate(input.location.lng), + expiresAt, + lat: input.location.lat, + lng: input.location.lng, + moodTags: input.moodTags ?? [], + placeName: input.placeName, + sessionId: input.sessionId, + trackSnapshot: trackSnapshot ? toInputJson(trackSnapshot) : undefined, + travelMode: input.travelMode, + visibility: input.visibility, + }, + create: { + id: createPublicId('sound_pin'), + approxLat: normalizeApproxCoordinate(input.location.lat), + approxLng: normalizeApproxCoordinate(input.location.lng), + expiresAt, + lat: input.location.lat, + lng: input.location.lng, + moodTags: input.moodTags ?? [], + placeName: input.placeName, + sessionId: input.sessionId, + trackSnapshot: trackSnapshot ? toInputJson(trackSnapshot) : undefined, + travelMode: input.travelMode, + userId, + visibility: input.visibility, + }, + include: { + user: { + select: { + displayName: true, + profile: { + select: { + preferredGenres: true, + preferredMoods: true, + travelStyles: true, + }, + }, + }, + }, + }, + }); + + return soundMapPinToDto(pin, userId, input.visibility !== 'nearby'); + }, + + async getSoundMapPins(userId: string, query: { + lat?: number; + lng?: number; + radiusMeters?: number; + visibility?: string; + }) { + const blockedUsers = await prisma.communityBlock.findMany({ + select: { blockedUserId: true }, + where: { blockerId: userId }, + }); + const pins = await prisma.soundMapPin.findMany({ + where: { + expiresAt: { gt: new Date() }, + userId: { notIn: blockedUsers.map((block) => block.blockedUserId) }, + visibility: query.visibility ?? { not: 'private' }, + }, + include: { + user: { + select: { + displayName: true, + profile: { + select: { + preferredGenres: true, + preferredMoods: true, + travelStyles: true, + }, + }, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + take: 50, + }); + + return filterPinsByRadius(pins, query).map((pin) => + soundMapPinToDto(pin, userId, pin.visibility !== 'nearby'), + ); + }, + + async getNearbySoundMatches(userId: string, query: { + lat?: number; + lng?: number; + mood?: string; + radiusMeters?: number; + state?: string; + }) { + const blockedUsers = await prisma.communityBlock.findMany({ + select: { blockedUserId: true }, + where: { blockerId: userId }, + }); + const pins = await prisma.soundMapPin.findMany({ + where: { + AND: [ + { userId: { not: userId } }, + { userId: { notIn: blockedUsers.map((block) => block.blockedUserId) } }, + ], + expiresAt: { gt: new Date() }, + visibility: 'nearby', + }, + include: { + user: { + select: { + displayName: true, + profile: { + select: { + preferredGenres: true, + preferredMoods: true, + travelStyles: true, + }, + }, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + take: 20, + }); + + return filterPinsByRadius(pins, query).map((pin) => ({ + ...soundMapPinToDto(pin, userId, false), + matchScore: scoreMatch(pin, query), + targetPinId: pin.id, + })); + }, + + async getMusicMatches(userId: string, query: { + lat?: number; + lng?: number; + mood?: string; + radiusMeters?: number; + state?: string; + }) { + const pins = await this.getNearbySoundMatches(userId, query); + + return pins + .map((pin) => ({ + id: `match-${pin.id}`, + pin, + targetPinId: pin.targetPinId, + matchScore: pin.matchScore, + safety: { + exactLocationHidden: true, + firstMessageTemplates: ['liked_track', 'walk_together', 'cafe_together'], + contactHiddenUntilAccepted: true, + }, + })) + .sort((first, second) => second.matchScore - first.matchScore); + }, + + async createTravelMateRequest(userId: string, input: { + messageTemplate: string; + targetPinId?: string; + targetUserId?: string; + }) { + const targetPin = input.targetPinId + ? await prisma.soundMapPin.findUnique({ where: { id: input.targetPinId } }) + : undefined; + const targetUserId = input.targetUserId ?? targetPin?.userId; + + if (!targetUserId) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); + } + + const existingActiveRequest = await prisma.travelMateRequest.findFirst({ + where: { + requesterId: userId, + status: { in: ['pending', 'accepted'] }, + targetPinId: input.targetPinId, + targetUserId, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (existingActiveRequest) { + return mateRequestToDto(existingActiveRequest); + } + + const request = await prisma.travelMateRequest.create({ + data: { + id: createPublicId('mate'), + messageTemplate: input.messageTemplate, + requesterId: userId, + status: 'pending', + targetPinId: input.targetPinId, + targetUserId, + }, + }); + + return mateRequestToDto(request); + }, + + async updateTravelMateRequest(userId: string, requestId: string, input: { action: string }) { + const request = await prisma.travelMateRequest.findFirst({ + where: { + id: requestId, + OR: [{ requesterId: userId }, { targetUserId: userId }], + }, + }); + + if (!request) { + throw notFound(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_NOT_FOUND); + } + + const nextStatusByAction: Record = { + accept: 'accepted', + cancel: 'cancelled', + decline: 'declined', + expire: 'expired', + }; + const updated = await prisma.travelMateRequest.update({ + where: { id: request.id }, + data: { status: nextStatusByAction[input.action] ?? request.status }, + }); + + return mateRequestToDto(updated); + }, + + async blockCommunityUser(userId: string, input: { targetPinId?: string; targetUserId?: string }) { + const targetPin = input.targetPinId + ? await prisma.soundMapPin.findUnique({ where: { id: input.targetPinId } }) + : undefined; + const targetUserId = input.targetUserId ?? targetPin?.userId; + + if (!targetUserId) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); + } + + await prisma.communityBlock.upsert({ + where: { + blockerId_blockedUserId: { + blockedUserId: targetUserId, + blockerId: userId, + }, + }, + update: {}, + create: { + blockedUserId: targetUserId, + blockerId: userId, + }, + }); + }, + + async reportCommunityTarget(userId: string, input: { + details?: string; + reason: string; + requestId?: string; + targetPinId?: string; + targetUserId?: string; + }) { + await prisma.communityReport.create({ + data: { + details: input.details, + reason: input.reason, + reporterId: userId, + requestId: input.requestId, + targetPinId: input.targetPinId, + targetUserId: input.targetUserId, + }, + }); + }, + async getRecaps(userId: string, params: { cursor?: string; limit?: number }) { const recaps = await prisma.recap.findMany({ where: { userId }, diff --git a/src/validators/api.validators.ts b/src/validators/api.validators.ts index 86cd580..5741cff 100644 --- a/src/validators/api.validators.ts +++ b/src/validators/api.validators.ts @@ -80,6 +80,9 @@ const geoPointSchema = z.object({ lng: z.number().min(-180).max(180), }); +const optionalLatQuery = z.coerce.number().min(-90).max(90).optional(); +const optionalLngQuery = z.coerce.number().min(-180).max(180).optional(); + const recommendationContextSchema = z .object({ moodFilter: z.string().optional(), @@ -310,6 +313,88 @@ export const travelSessionValidators = { }), }; +export const communityValidators = { + roomParams: z.object({ + roomId: z.string().min(1), + }), + createRoomBody: z.object({ + sessionId: z.string().optional(), + title: z.string().trim().min(1).max(80).default('Soundlog 여행방'), + visibility: z.enum(['invite_only', 'companions']).optional().default('invite_only'), + }), + joinRoomBody: z.object({ + displayName: z.string().trim().min(1).max(40).optional(), + inviteCode: z.string().trim().min(4).max(16).optional(), + }), + addRoomMomentBody: z.object({ + artistName: z.string().trim().max(120).optional(), + momentLogId: z.string().optional(), + note: z.string().trim().max(240).optional(), + placeName: z.string().trim().max(120).optional(), + status: z.enum(['candidate', 'accepted']).optional().default('candidate'), + trackId: z.string().optional(), + trackTitle: z.string().trim().max(160).optional(), + }), + createRoomRecapBody: z.object({ + representativeTrackId: z.string().optional(), + templateId: z.enum(['album', 'film', 'lp']).optional().default('album'), + title: z.string().trim().max(120).optional(), + }), + soundMapQuery: z.object({ + lat: optionalLatQuery, + lng: optionalLngQuery, + radiusMeters: z.coerce.number().int().min(100).max(20000).optional().default(3000), + visibility: z.enum(['companions', 'nearby']).optional(), + }), + currentTrackBody: z.object({ + location: geoPointSchema, + moodTags: z.array(moodTagSchema).optional().default([]), + placeName: z.string().trim().max(120).optional(), + sessionId: z.string().optional(), + trackId: z.string().optional(), + trackTitle: z.string().trim().max(160).optional(), + artistName: z.string().trim().max(120).optional(), + travelMode: travelModeSchema.optional(), + ttlMinutes: z.number().int().min(5).max(240).optional().default(120), + visibility: z.enum(['companions', 'nearby', 'private']), + }), + musicMatchesQuery: z.object({ + lat: optionalLatQuery, + lng: optionalLngQuery, + mood: z.string().optional(), + radiusMeters: z.coerce.number().int().min(100).max(20000).optional().default(3000), + state: z.string().optional(), + }), + createMateRequestBody: z.object({ + messageTemplate: z + .enum(['liked_track', 'walk_together', 'cafe_together']) + .default('liked_track'), + targetPinId: z.string().optional(), + targetUserId: z.string().optional(), + }), + mateRequestParams: z.object({ + requestId: z.string().min(1), + }), + updateMateRequestBody: z.object({ + action: z.enum(['accept', 'decline', 'cancel', 'expire']), + }), + blockBody: z + .object({ + targetPinId: z.string().optional(), + targetUserId: z.string().min(1).optional(), + }) + .refine((value) => Boolean(value.targetUserId || value.targetPinId), { + message: ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED, + }), + reportBody: z.object({ + details: z.string().trim().max(500).optional(), + reason: z.enum(['safety', 'spam', 'inappropriate', 'other']), + requestId: z.string().optional(), + targetPinId: z.string().optional(), + targetUserId: z.string().optional(), + }), +}; + export const trendValidators = { params: z.object({ regionCode: z.string().min(1), diff --git a/tests/api.test.ts b/tests/api.test.ts index aef7ce7..28400d8 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -515,6 +515,183 @@ describe('Soundlog API', () => { expect(updated.body.data.status).toBe('ended'); }); + it('handles travel rooms, sound map, and mate requests', async () => { + const targetEmail = `target-${Date.now()}@soundlog.test`; + const targetPassword = 'soundlog-password'; + const targetRegister = await request(app).post('/v1/auth/register').send({ + displayName: 'Nearby Sound Traveler', + email: targetEmail, + password: targetPassword, + }); + expect(targetRegister.status).toBe(201); + const targetUserId = targetRegister.body.data.user.id; + const targetLogin = await request(app).post('/v1/auth/login').send({ + email: targetEmail, + password: targetPassword, + }); + expect(targetLogin.status).toBe(200); + const targetAuthHeader = `Bearer ${targetLogin.body.data.accessToken}`; + + const room = await request(app) + .post('/v1/travel-rooms') + .set('Authorization', authHeader) + .send({ + sessionId: 'community-session', + title: '강릉 사운드 여행방', + }); + expect(room.status).toBe(201); + expect(room.body.data.inviteCode).toEqual(expect.any(String)); + + const joined = await request(app) + .post(`/v1/travel-rooms/${room.body.data.id}/join`) + .set('Authorization', targetAuthHeader) + .send({ + displayName: '수경', + inviteCode: room.body.data.inviteCode, + }); + expect(joined.status).toBe(200); + expect(joined.body.data.memberCount).toBe(2); + + const sharedMoment = await request(app) + .post(`/v1/travel-rooms/${room.body.data.id}/moments`) + .set('Authorization', targetAuthHeader) + .send({ + note: '주문진 바다 컷', + placeName: '주문진 해변', + status: 'accepted', + trackId: 'seoul-night-track', + }); + expect(sharedMoment.status).toBe(201); + expect(sharedMoment.body.data.track.id).toBe('seoul-night-track'); + + const roomRecap = await request(app) + .post(`/v1/travel-rooms/${room.body.data.id}/recaps`) + .set('Authorization', authHeader) + .send({ + templateId: 'album', + title: '강릉 공동 Recap', + }); + expect(roomRecap.status).toBe(201); + expect(roomRecap.body.data.roomId).toBe(room.body.data.id); + + const targetPin = await request(app) + .post('/v1/sound-map/current-track') + .set('Authorization', targetAuthHeader) + .send({ + location: { lat: 37.752, lng: 128.876 }, + moodTags: ['calm'], + placeName: '강릉 카페거리', + sessionId: 'community-session', + trackId: 'seoul-night-track', + travelMode: 'walk', + visibility: 'nearby', + }); + expect(targetPin.status).toBe(202); + expect(targetPin.body.data.visibility).toBe('nearby'); + + const myPin = await request(app) + .post('/v1/sound-map/current-track') + .set('Authorization', authHeader) + .send({ + location: { lat: 37.751, lng: 128.875 }, + moodTags: ['fresh'], + placeName: '강릉역', + sessionId: 'community-session', + trackId: 'seoul-city', + travelMode: 'walk', + visibility: 'companions', + }); + expect(myPin.status).toBe(202); + + const map = await request(app) + .get('/v1/sound-map') + .set('Authorization', authHeader) + .query({ lat: 37.751, lng: 128.875, radiusMeters: 3000 }); + expect(map.status).toBe(200); + expect(map.body.data.length).toBeGreaterThanOrEqual(2); + + const smallRadiusMap = await request(app) + .get('/v1/sound-map') + .set('Authorization', authHeader) + .query({ lat: 37.751, lng: 128.875, radiusMeters: 100 }); + expect(smallRadiusMap.status).toBe(200); + expect(smallRadiusMap.body.data.map((pin: { id: string }) => pin.id)).not.toContain( + targetPin.body.data.id, + ); + + const emptyNearbyByRadius = await request(app) + .get('/v1/sound-map/nearby') + .set('Authorization', authHeader) + .query({ lat: 37.751, lng: 128.875, radiusMeters: 100 }); + expect(emptyNearbyByRadius.status).toBe(200); + expect(emptyNearbyByRadius.body.data).toEqual([]); + + const nearby = await request(app) + .get('/v1/sound-map/nearby') + .set('Authorization', authHeader) + .query({ mood: '잔잔한', state: '산책' }); + expect(nearby.status).toBe(200); + expect(nearby.body.data[0].targetPinId).toBe(targetPin.body.data.id); + + const matches = await request(app) + .get('/v1/music-matches') + .set('Authorization', authHeader) + .query({ mood: '잔잔한', state: '산책' }); + expect(matches.status).toBe(200); + expect(matches.body.data[0].targetPinId).toBe(targetPin.body.data.id); + expect(matches.body.data[0].safety.exactLocationHidden).toBe(true); + + const mateRequest = await request(app) + .post('/v1/travel-mate-requests') + .set('Authorization', authHeader) + .send({ + messageTemplate: 'liked_track', + targetPinId: targetPin.body.data.id, + }); + expect(mateRequest.status).toBe(201); + expect(mateRequest.body.data.status).toBe('pending'); + + const duplicateMateRequest = await request(app) + .post('/v1/travel-mate-requests') + .set('Authorization', authHeader) + .send({ + messageTemplate: 'liked_track', + targetPinId: targetPin.body.data.id, + }); + expect(duplicateMateRequest.status).toBe(201); + expect(duplicateMateRequest.body.data.id).toBe(mateRequest.body.data.id); + + const accepted = await request(app) + .patch(`/v1/travel-mate-requests/${mateRequest.body.data.id}`) + .set('Authorization', targetAuthHeader) + .send({ action: 'accept' }); + expect(accepted.status).toBe(200); + expect(accepted.body.data.status).toBe('accepted'); + + const report = await request(app) + .post('/v1/community/reports') + .set('Authorization', authHeader) + .send({ + reason: 'safety', + targetPinId: targetPin.body.data.id, + targetUserId, + }); + expect(report.status).toBe(202); + + const block = await request(app) + .post('/v1/community/blocks') + .set('Authorization', authHeader) + .send({ targetPinId: targetPin.body.data.id }); + expect(block.status).toBe(202); + + const hiddenMatches = await request(app) + .get('/v1/music-matches') + .set('Authorization', authHeader) + .query({ mood: '잔잔한', state: '산책' }); + expect(hiddenMatches.status).toBe(200); + expect(hiddenMatches.body.data).toEqual([]); + }); + it('returns regional trends without auth', async () => { const response = await request(app) .get('/v1/trends/regions/KR-26/sound') From ca41307929da2bebfc6b9553e4ab3970f005e0ec Mon Sep 17 00:00:00 2001 From: manNomi Date: Wed, 8 Jul 2026 11:51:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EC=84=9C=EB=B2=84=20=EC=A0=95=EC=B1=85=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openapi/soundlog-api.yaml | 139 +++++++ .../migration.sql | 16 + prisma/models/auth.prisma | 55 +-- prisma/models/community.prisma | 63 +-- src/constants/error.constants.ts | 8 + src/controllers/community.controller.ts | 28 ++ src/mock/mock-db.ts | 11 + src/routes/index.ts | 18 + src/services/mock-soundlog.service.ts | 338 +++++++++++++++- src/services/soundlog.service.ts | 377 ++++++++++++++++-- src/utils/http-error.ts | 4 + src/validators/api.validators.ts | 29 +- tests/api.test.ts | 75 +++- 13 files changed, 1050 insertions(+), 111 deletions(-) create mode 100644 prisma/migrations/20260708000000_travel_room_moment_comments/migration.sql diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index 6937d60..92bec8c 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -878,6 +878,77 @@ paths: "404": $ref: "#/components/responses/NotFound" + /v1/travel-rooms/{roomId}/moments/{momentId}: + patch: + tags: + - Community + summary: 공동 Recap 후보 상태 변경 + description: 방장만 후보를 candidate, accepted, rejected 상태로 변경할 수 있습니다. + operationId: updateTravelRoomMoment + parameters: + - name: roomId + in: path + required: true + schema: + type: string + - name: momentId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomMomentUpdateRequest" + responses: + "200": + description: 변경된 공동 순간 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomMomentResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /v1/travel-rooms/{roomId}/moments/{momentId}/comments: + post: + tags: + - Community + summary: 공동 Recap 후보 댓글 추가 + operationId: addTravelRoomMomentComment + parameters: + - name: roomId + in: path + required: true + schema: + type: string + - name: momentId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomMomentCommentCreateRequest" + responses: + "201": + description: 추가된 댓글 + content: + application/json: + schema: + $ref: "#/components/schemas/TravelRoomMomentCommentResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /v1/travel-rooms/{roomId}/recaps: post: tags: @@ -963,6 +1034,8 @@ paths: application/json: schema: $ref: "#/components/schemas/SoundMapPinResponse" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" @@ -1093,6 +1166,8 @@ paths: $ref: "#/components/schemas/TravelMateRequestResponse" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" @@ -1325,6 +1400,12 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + Forbidden: + description: 권한 없음 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" NotFound: description: 리소스를 찾을 수 없음 content: @@ -2247,8 +2328,32 @@ components: enum: - candidate - accepted + - rejected default: candidate + TravelRoomMomentUpdateRequest: + type: object + required: + - status + properties: + status: + type: string + enum: + - candidate + - accepted + - rejected + + TravelRoomMomentCommentCreateRequest: + type: object + required: + - body + properties: + body: + type: string + minLength: 1 + maxLength: 300 + example: 이 컷은 첫 번째 페이지에 넣자 + TravelRoomRecapCreateRequest: type: object properties: @@ -2311,8 +2416,33 @@ components: enum: - candidate - accepted + - rejected track: $ref: "#/components/schemas/Track" + commentCount: + type: integer + comments: + type: array + items: + $ref: "#/components/schemas/TravelRoomMomentComment" + createdAt: + type: string + format: date-time + + TravelRoomMomentComment: + type: object + required: + - id + - userId + - body + - createdAt + properties: + id: + type: string + userId: + type: string + body: + type: string createdAt: type: string format: date-time @@ -2367,6 +2497,7 @@ components: type: object required: - location + - sessionId - visibility properties: location: @@ -2616,6 +2747,14 @@ components: data: $ref: "#/components/schemas/TravelRoomMoment" + TravelRoomMomentCommentResponse: + type: object + required: + - data + properties: + data: + $ref: "#/components/schemas/TravelRoomMomentComment" + SoundMapPinResponse: type: object required: diff --git a/prisma/migrations/20260708000000_travel_room_moment_comments/migration.sql b/prisma/migrations/20260708000000_travel_room_moment_comments/migration.sql new file mode 100644 index 0000000..96a8394 --- /dev/null +++ b/prisma/migrations/20260708000000_travel_room_moment_comments/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "TravelRoomMomentComment" ( + "id" TEXT NOT NULL, + "momentId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "body" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TravelRoomMomentComment_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "TravelRoomMomentComment" ADD CONSTRAINT "TravelRoomMomentComment_momentId_fkey" FOREIGN KEY ("momentId") REFERENCES "TravelRoomMoment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TravelRoomMomentComment" ADD CONSTRAINT "TravelRoomMomentComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/auth.prisma b/prisma/models/auth.prisma index 112db2a..f0c41e4 100644 --- a/prisma/models/auth.prisma +++ b/prisma/models/auth.prisma @@ -1,31 +1,32 @@ model User { - id String @id @default(cuid()) - provider String - providerUserId String - displayName String? - passwordHash String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - profile UserProfile? - musicPlatform MusicPlatform? - refreshTokens RefreshToken[] - libraryTrackStates LibraryTrackState[] - momentLogs MomentLog[] - recommendationEvents RecommendationEvent[] - recaps Recap[] - travelSessions TravelSession[] - recapShareEvents RecapShareEvent[] - idempotencyRecords IdempotencyRecord[] - ownedTravelRooms TravelRoom[] @relation("TravelRoomOwner") - travelRoomMembers TravelRoomMember[] - travelRoomMoments TravelRoomMoment[] - soundMapPins SoundMapPin[] - sentMateRequests TravelMateRequest[] @relation("TravelMateRequester") - receivedMateRequests TravelMateRequest[] @relation("TravelMateTarget") - communityBlocksMade CommunityBlock[] @relation("CommunityBlocker") - communityBlocksTaken CommunityBlock[] @relation("CommunityBlocked") - communityReports CommunityReport[] @relation("CommunityReporter") - communityReportsOn CommunityReport[] @relation("CommunityReportedUser") + id String @id @default(cuid()) + provider String + providerUserId String + displayName String? + passwordHash String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + profile UserProfile? + musicPlatform MusicPlatform? + refreshTokens RefreshToken[] + libraryTrackStates LibraryTrackState[] + momentLogs MomentLog[] + recommendationEvents RecommendationEvent[] + recaps Recap[] + travelSessions TravelSession[] + recapShareEvents RecapShareEvent[] + idempotencyRecords IdempotencyRecord[] + ownedTravelRooms TravelRoom[] @relation("TravelRoomOwner") + travelRoomMembers TravelRoomMember[] + travelRoomMoments TravelRoomMoment[] + travelRoomMomentComments TravelRoomMomentComment[] + soundMapPins SoundMapPin[] + sentMateRequests TravelMateRequest[] @relation("TravelMateRequester") + receivedMateRequests TravelMateRequest[] @relation("TravelMateTarget") + communityBlocksMade CommunityBlock[] @relation("CommunityBlocker") + communityBlocksTaken CommunityBlock[] @relation("CommunityBlocked") + communityReports CommunityReport[] @relation("CommunityReporter") + communityReportsOn CommunityReport[] @relation("CommunityReportedUser") @@unique([provider, providerUserId]) } diff --git a/prisma/models/community.prisma b/prisma/models/community.prisma index 6757352..5572818 100644 --- a/prisma/models/community.prisma +++ b/prisma/models/community.prisma @@ -26,22 +26,33 @@ model TravelRoomMember { } model TravelRoomMoment { - id String @id + id String @id roomId String userId String momentLogId String? placeName String? note String? - status String @default("candidate") + status String @default("candidate") trackSnapshot Json? - createdAt DateTime @default(now()) - room TravelRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + room TravelRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + comments TravelRoomMomentComment[] +} + +model TravelRoomMomentComment { + id String @id @default(cuid()) + momentId String + userId String + body String + createdAt DateTime @default(now()) + moment TravelRoomMoment @relation(fields: [momentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model SoundMapPin { - id String @id - userId String @unique + id String @id + userId String @unique sessionId String? visibility String lat Float @@ -53,22 +64,22 @@ model SoundMapPin { placeName String? trackSnapshot Json? expiresAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model TravelMateRequest { - id String @id + id String @id requesterId String targetUserId String targetPinId String? messageTemplate String status String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - requester User @relation("TravelMateRequester", fields: [requesterId], references: [id], onDelete: Cascade) - targetUser User @relation("TravelMateTarget", fields: [targetUserId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requester User @relation("TravelMateRequester", fields: [requesterId], references: [id], onDelete: Cascade) + targetUser User @relation("TravelMateTarget", fields: [targetUserId], references: [id], onDelete: Cascade) reports CommunityReport[] } @@ -84,15 +95,15 @@ model CommunityBlock { } model CommunityReport { - id String @id @default(cuid()) - reporterId String - targetUserId String? - targetPinId String? - requestId String? - reason String - details String? - createdAt DateTime @default(now()) - reporter User @relation("CommunityReporter", fields: [reporterId], references: [id], onDelete: Cascade) - targetUser User? @relation("CommunityReportedUser", fields: [targetUserId], references: [id], onDelete: SetNull) - mateRequest TravelMateRequest? @relation(fields: [requestId], references: [id], onDelete: SetNull) + id String @id @default(cuid()) + reporterId String + targetUserId String? + targetPinId String? + requestId String? + reason String + details String? + createdAt DateTime @default(now()) + reporter User @relation("CommunityReporter", fields: [reporterId], references: [id], onDelete: Cascade) + targetUser User? @relation("CommunityReportedUser", fields: [targetUserId], references: [id], onDelete: SetNull) + mateRequest TravelMateRequest? @relation(fields: [requestId], references: [id], onDelete: SetNull) } diff --git a/src/constants/error.constants.ts b/src/constants/error.constants.ts index a452ba3..9f963fa 100644 --- a/src/constants/error.constants.ts +++ b/src/constants/error.constants.ts @@ -1,6 +1,7 @@ export const ERROR_CODES = { BAD_REQUEST: 'BAD_REQUEST', DATABASE_ERROR: 'DATABASE_ERROR', + FORBIDDEN: 'FORBIDDEN', INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', NOT_FOUND: 'NOT_FOUND', UNAUTHORIZED: 'UNAUTHORIZED', @@ -11,6 +12,7 @@ export const ERROR_MESSAGES = { DATABASE_ERROR: '데이터 처리 중 오류가 발생했습니다.', ENDED_TRAVEL_SESSION_CANNOT_ACTIVATE: '종료된 여행 세션은 다시 활성화할 수 없습니다.', FILE_UPLOAD_INVALID: '파일 업로드 요청이 올바르지 않습니다.', + FORBIDDEN: '요청한 작업을 수행할 권한이 없습니다.', IDEMPOTENCY_IN_PROGRESS: '동일한 요청이 아직 처리 중입니다.', INTERNAL_SERVER_ERROR: '서버 오류가 발생했습니다.', INVALID_EMAIL_OR_PASSWORD: '이메일 또는 비밀번호가 올바르지 않습니다.', @@ -19,6 +21,7 @@ export const ERROR_MESSAGES = { INVALID_TOKEN: '유효하지 않은 토큰입니다.', PHOTO_REQUIRED: 'photo 파일이 필요합니다.', PLAYLIST_NOT_FOUND: '플레이리스트를 찾을 수 없습니다.', + MOMENT_LOG_NOT_FOUND: 'Moment Log를 찾을 수 없습니다.', USER_ALREADY_EXISTS: '이미 가입된 이메일입니다.', RECAP_NOT_FOUND: '리캡을 찾을 수 없습니다.', RECOMMENDATION_TRACK_NOT_FOUND: '추천 트랙을 찾을 수 없습니다.', @@ -27,9 +30,14 @@ export const ERROR_MESSAGES = { RESOURCE_NOT_FOUND: '요청한 리소스를 찾을 수 없습니다.', ROUTE_NOT_FOUND: '요청한 API 경로를 찾을 수 없습니다.', TRACK_NOT_FOUND: '트랙을 찾을 수 없습니다.', + TRAVEL_MATE_REQUEST_ACTION_NOT_ALLOWED: '이 동행 매칭 요청을 처리할 권한이 없습니다.', + TRAVEL_MATE_REQUEST_NOT_PENDING: '대기 중인 동행 매칭 요청만 변경할 수 있습니다.', TRAVEL_MATE_REQUEST_NOT_FOUND: '동행 매칭 요청을 찾을 수 없습니다.', + TRAVEL_MATE_SELF_REQUEST_NOT_ALLOWED: '자기 자신에게는 동행 매칭 요청을 보낼 수 없습니다.', TRAVEL_MATE_TARGET_REQUIRED: '동행 매칭 대상이 필요합니다.', TRAVEL_ROOM_INVITE_CODE_INVALID: '여행방 초대 코드가 올바르지 않습니다.', + TRAVEL_ROOM_MOMENT_NOT_FOUND: '공동 Recap 후보 Moment를 찾을 수 없습니다.', TRAVEL_ROOM_NOT_FOUND: '여행방을 찾을 수 없습니다.', + TRAVEL_SESSION_ACTIVE_REQUIRED: '여행 모드가 켜진 활성 세션에서만 사용할 수 있습니다.', TRAVEL_SESSION_NOT_FOUND: '여행 세션을 찾을 수 없습니다.', } as const; diff --git a/src/controllers/community.controller.ts b/src/controllers/community.controller.ts index 5714de3..1f015f1 100644 --- a/src/controllers/community.controller.ts +++ b/src/controllers/community.controller.ts @@ -33,6 +33,34 @@ export const communityController = { ); }, + async updateTravelRoomMoment(req: Request, res: Response) { + const user = requireUser(req); + res.json( + dataResponse( + await apiService.updateTravelRoomMoment( + user.id, + String(req.params.roomId), + String(req.params.momentId), + req.body, + ), + ), + ); + }, + + async addTravelRoomMomentComment(req: Request, res: Response) { + const user = requireUser(req); + res.status(201).json( + dataResponse( + await apiService.addTravelRoomMomentComment( + user.id, + String(req.params.roomId), + String(req.params.momentId), + req.body, + ), + ), + ); + }, + async createTravelRoomRecap(req: Request, res: Response) { const user = requireUser(req); res.status(201).json( diff --git a/src/mock/mock-db.ts b/src/mock/mock-db.ts index ff8559e..e25a1de 100644 --- a/src/mock/mock-db.ts +++ b/src/mock/mock-db.ts @@ -69,6 +69,7 @@ type MockTravelSession = { startedAt?: Date; status: 'active' | 'ended' | 'idle'; travelMode?: string; + userId: string; }; type MockRefreshToken = { @@ -115,6 +116,14 @@ type MockTravelRoomMoment = { userId: string; }; +type MockTravelRoomMomentComment = { + body: string; + createdAt: Date; + id: string; + momentId: string; + userId: string; +}; + type MockSoundMapPin = { approxLat: number; approxLng: number; @@ -234,6 +243,7 @@ function createMockDb() { sessionId: string; trackId?: string; type: string; + userId: string; value?: string; }>, recaps: recaps.map((recap) => ({ @@ -266,6 +276,7 @@ function createMockDb() { travelRooms: [] as MockTravelRoom[], travelRoomMembers: [] as MockTravelRoomMember[], travelRoomMoments: [] as MockTravelRoomMoment[], + travelRoomMomentComments: [] as MockTravelRoomMomentComment[], soundMapPins: [] as MockSoundMapPin[], travelMateRequests: [] as MockTravelMateRequest[], communityBlocks: [] as Array<{ diff --git a/src/routes/index.ts b/src/routes/index.ts index 153627a..173e0d2 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -218,6 +218,24 @@ export function createApiRouter() { }), asyncHandler(communityController.addTravelRoomMoment), ); + router.patch( + '/v1/travel-rooms/:roomId/moments/:momentId', + authMiddleware, + validate({ + params: communityValidators.roomMomentParams, + body: communityValidators.updateRoomMomentBody, + }), + asyncHandler(communityController.updateTravelRoomMoment), + ); + router.post( + '/v1/travel-rooms/:roomId/moments/:momentId/comments', + authMiddleware, + validate({ + params: communityValidators.roomMomentParams, + body: communityValidators.addRoomMomentCommentBody, + }), + asyncHandler(communityController.addTravelRoomMomentComment), + ); router.post( '/v1/travel-rooms/:roomId/recaps', authMiddleware, diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index ab137ee..c43b6db 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -1,7 +1,7 @@ import { env } from '../config/env.js'; import { ERROR_MESSAGES } from '../constants/error.constants.js'; import { findMockTrack, mockDb } from '../mock/mock-db.js'; -import { badRequest, notFound } from '../utils/http-error.js'; +import { badRequest, forbidden, notFound } from '../utils/http-error.js'; import { getLimit, paginateByCursor } from '../utils/pagination.js'; import { createPublicId } from '../utils/tokens.js'; @@ -199,6 +199,10 @@ function filterPinsByRadius( ); } +function hasGeoPoint(query: { lat?: number; lng?: number }) { + return query.lat !== undefined && query.lng !== undefined; +} + function createInviteCode() { return Math.random().toString(36).slice(2, 8).toUpperCase(); } @@ -242,6 +246,17 @@ function roomToDto(room: (typeof mockDb.travelRooms)[number]) { note: moment.note, status: moment.status, track: moment.trackSnapshot, + commentCount: mockDb.travelRoomMomentComments.filter( + (comment) => comment.momentId === moment.id, + ).length, + comments: mockDb.travelRoomMomentComments + .filter((comment) => comment.momentId === moment.id) + .map((comment) => ({ + id: comment.id, + userId: comment.userId, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + })), createdAt: moment.createdAt.toISOString(), })), createdAt: room.createdAt.toISOString(), @@ -252,13 +267,18 @@ function roomToDto(room: (typeof mockDb.travelRooms)[number]) { function soundMapPinToDto(pin: (typeof mockDb.soundMapPins)[number], viewerId: string) { const isMine = pin.userId === viewerId; const profile = getMockUserProfile(pin.userId); + const alias = isMine + ? '나' + : pin.visibility === 'nearby' + ? '근처 여행자' + : profile.displayName ?? '동행자'; return { id: pin.id, - alias: isMine ? '나' : profile.displayName ?? '근처 여행자', + alias, isMine, visibility: pin.visibility, - location: isMine || pin.visibility !== 'nearby' + location: isMine ? { lat: pin.lat, lng: pin.lng } : { lat: pin.approxLat, lng: pin.approxLng }, moodTags: pin.moodTags, @@ -276,7 +296,12 @@ function soundMapPinToDto(pin: (typeof mockDb.soundMapPins)[number], viewerId: s }; } -function scoreMockMatch(pin: (typeof mockDb.soundMapPins)[number], params: { mood?: string; state?: string }) { +function scoreMockMatch(pin: (typeof mockDb.soundMapPins)[number], params: { + lat?: number; + lng?: number; + mood?: string; + state?: string; +}) { let score = 70; if (params.mood && mockDb.profile.preferredMoods.some((mood) => params.mood?.includes(mood))) { score += 8; @@ -287,9 +312,46 @@ function scoreMockMatch(pin: (typeof mockDb.soundMapPins)[number], params: { moo if (pin.trackSnapshot) { score += 6; } + if (params.lat !== undefined && params.lng !== undefined) { + const distance = distanceMeters({ lat: params.lat, lng: params.lng }, pin); + if (distance <= 500) { + score += 8; + } else if (distance <= 1500) { + score += 4; + } + } + + const minutesSinceUpdate = (Date.now() - pin.updatedAt.getTime()) / 60_000; + if (minutesSinceUpdate <= 30) { + score += 6; + } else if (minutesSinceUpdate <= 120) { + score += 3; + } return Math.min(score, 96); } +function recordMockCommunityRecommendationEvent( + userId: string, + type: string, + context: Record, + input?: { + sessionId?: string; + trackId?: string; + value?: string; + }, +) { + mockDb.recommendationEvents.push({ + id: createPublicId('event'), + userId, + sessionId: input?.sessionId ?? String(context.sessionId ?? context.roomId ?? 'community'), + type, + trackId: input?.trackId, + value: input?.value, + context, + createdAt: new Date(), + }); +} + function mateRequestToDto(request: (typeof mockDb.travelMateRequests)[number]) { return { ...request, @@ -714,7 +776,7 @@ export const mockSoundlogService = { ); }, - async createRecommendationEvents(_userId: string, input: { + async createRecommendationEvents(userId: string, input: { events: Array<{ context: Record; createdAt: string; @@ -733,6 +795,7 @@ export const mockSoundlogService = { mockDb.recommendationEvents.push({ ...event, + userId, createdAt: new Date(event.createdAt), }); }); @@ -763,6 +826,11 @@ export const mockSoundlogService = { userId, }); + recordMockCommunityRecommendationEvent(userId, 'trip_room_created', { + roomId: room.id, + visibility: room.visibility, + }, { sessionId: room.sessionId ?? room.id }); + return roomToDto(room); }, @@ -789,14 +857,18 @@ export const mockSoundlogService = { throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); } - if (input.inviteCode && input.inviteCode !== room.inviteCode) { - throw badRequest(ERROR_MESSAGES.TRAVEL_ROOM_INVITE_CODE_INVALID); - } - const existing = mockDb.travelRoomMembers.find( (member) => member.roomId === roomId && member.userId === userId, ); + if (!existing && input.inviteCode !== room.inviteCode) { + throw badRequest(ERROR_MESSAGES.TRAVEL_ROOM_INVITE_CODE_INVALID); + } + + if (existing && input.inviteCode && input.inviteCode !== room.inviteCode) { + throw badRequest(ERROR_MESSAGES.TRAVEL_ROOM_INVITE_CODE_INVALID); + } + if (existing) { existing.displayName = input.displayName; } else { @@ -810,6 +882,11 @@ export const mockSoundlogService = { }); } + recordMockCommunityRecommendationEvent(userId, 'trip_room_joined', { + role: existing?.role ?? 'member', + roomId, + }, { sessionId: room.sessionId ?? roomId }); + return roomToDto(room); }, @@ -833,6 +910,11 @@ export const mockSoundlogService = { const momentLog = input.momentLogId ? mockDb.momentLogs.find((moment) => moment.id === input.momentLogId) : undefined; + + if (input.momentLogId && !momentLog) { + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + const track = findMockTrack(input.trackId) ?? momentLog?.trackSnapshot ?? { id: input.trackId ?? createPublicId('track'), @@ -852,6 +934,15 @@ export const mockSoundlogService = { }; mockDb.travelRoomMoments.push(moment); + recordMockCommunityRecommendationEvent(userId, 'shared_moment_added', { + momentId: moment.id, + placeName: moment.placeName, + roomId, + }, { + sessionId: roomId, + trackId: moment.trackSnapshot?.id, + }); + return { id: moment.id, userId: moment.userId, @@ -864,6 +955,94 @@ export const mockSoundlogService = { }; }, + async updateTravelRoomMoment(userId: string, roomId: string, momentId: string, input: { + status: string; + }) { + const room = mockDb.travelRooms.find((item) => item.id === roomId && item.ownerId === userId); + + if (!room) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + const moment = mockDb.travelRoomMoments.find( + (item) => item.id === momentId && item.roomId === roomId, + ); + + if (!moment) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_MOMENT_NOT_FOUND); + } + + moment.status = input.status; + + recordMockCommunityRecommendationEvent(userId, 'shared_moment_status_updated', { + momentId, + roomId, + status: input.status, + }, { sessionId: room.sessionId ?? roomId }); + + const comments = mockDb.travelRoomMomentComments.filter((comment) => comment.momentId === moment.id); + + return { + id: moment.id, + userId: moment.userId, + momentLogId: moment.momentLogId, + note: moment.note, + placeName: moment.placeName, + status: moment.status, + track: moment.trackSnapshot, + commentCount: comments.length, + comments: comments.map((comment) => ({ + id: comment.id, + userId: comment.userId, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + })), + createdAt: moment.createdAt.toISOString(), + }; + }, + + async addTravelRoomMomentComment(userId: string, roomId: string, momentId: string, input: { + body: string; + }) { + const isMember = mockDb.travelRoomMembers.some( + (member) => member.roomId === roomId && member.userId === userId, + ); + + if (!isMember) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + const moment = mockDb.travelRoomMoments.find( + (item) => item.id === momentId && item.roomId === roomId, + ); + + if (!moment) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_MOMENT_NOT_FOUND); + } + + const room = mockDb.travelRooms.find((item) => item.id === roomId); + const comment = { + id: createPublicId('room_comment'), + body: input.body, + createdAt: new Date(), + momentId, + userId, + }; + mockDb.travelRoomMomentComments.push(comment); + + recordMockCommunityRecommendationEvent(userId, 'shared_moment_commented', { + momentId, + roomId, + }, { sessionId: room?.sessionId ?? roomId }); + + return { + id: comment.id, + userId: comment.userId, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + }; + }, + async createTravelRoomRecap(userId: string, roomId: string, input: { representativeTrackId?: string; templateId?: string; @@ -877,9 +1056,12 @@ export const mockSoundlogService = { throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); } const moments = mockDb.travelRoomMoments.filter((moment) => moment.roomId === roomId); + const recapMoments = moments.some((moment) => moment.status === 'accepted') + ? moments.filter((moment) => moment.status === 'accepted') + : moments; const representativeTrackId = input.representativeTrackId ?? - moments.find((moment) => moment.trackSnapshot?.id)?.trackSnapshot?.id ?? + recapMoments.find((moment) => moment.trackSnapshot?.id)?.trackSnapshot?.id ?? 'seoul-city'; if (!findMockTrack(representativeTrackId)) { @@ -889,13 +1071,13 @@ export const mockSoundlogService = { const recap = { id: createPublicId('recap'), title: input.title ?? `${room.title} 공동 Recap`, - placeName: moments[0]?.placeName ?? room.title, + placeName: recapMoments[0]?.placeName ?? room.title, representativeTrackId, createdAt: new Date(), - momentCount: moments.length, + momentCount: recapMoments.length, sessionId: room.sessionId, - recordedAt: moments[0]?.createdAt ?? new Date(), - moments: moments.map((moment) => ({ + recordedAt: recapMoments[0]?.createdAt ?? new Date(), + moments: recapMoments.map((moment) => ({ id: moment.id, placeName: moment.placeName ?? '위치 없음', trackTitle: moment.trackSnapshot?.title ?? '저장된 순간', @@ -905,6 +1087,15 @@ export const mockSoundlogService = { }; mockDb.recaps.unshift(recap); + recordMockCommunityRecommendationEvent(userId, 'collab_recap_created', { + recapId: recap.id, + roomId, + templateId: input.templateId, + }, { + sessionId: room.sessionId ?? roomId, + trackId: representativeTrackId, + }); + return compact({ ...recapItemToDto(recap), roomId, @@ -926,6 +1117,18 @@ export const mockSoundlogService = { ttlMinutes?: number; visibility: string; }) { + if (!input.sessionId) { + throw badRequest(ERROR_MESSAGES.TRAVEL_SESSION_ACTIVE_REQUIRED); + } + + const session = mockDb.travelSessions.find( + (item) => item.id === input.sessionId && item.status === 'active' && item.userId === userId, + ); + + if (!session) { + throw badRequest(ERROR_MESSAGES.TRAVEL_SESSION_ACTIVE_REQUIRED); + } + const now = new Date(); const track = findMockTrack(input.trackId) ?? { id: input.trackId ?? createPublicId('track'), @@ -936,13 +1139,13 @@ export const mockSoundlogService = { const nextPin = { id: pin?.id ?? createPublicId('sound_pin'), userId, - sessionId: input.sessionId, + sessionId: session.id, visibility: input.visibility, lat: input.location.lat, lng: input.location.lng, approxLat: normalizeApproxCoordinate(input.location.lat), approxLng: normalizeApproxCoordinate(input.location.lng), - travelMode: input.travelMode, + travelMode: input.travelMode ?? session.travelMode, moodTags: input.moodTags ?? [], placeName: input.placeName, trackSnapshot: track, @@ -957,6 +1160,15 @@ export const mockSoundlogService = { mockDb.soundMapPins.push(nextPin); } + recordMockCommunityRecommendationEvent(userId, 'live_track_shared', { + placeName: input.placeName, + visibility: input.visibility, + }, { + sessionId: session.id, + trackId: nextPin.trackSnapshot?.id, + value: input.visibility, + }); + return soundMapPinToDto(nextPin, userId); }, @@ -974,7 +1186,17 @@ export const mockSoundlogService = { .filter((pin) => !blockedIds.includes(pin.userId)) .filter((pin) => (query.visibility ? pin.visibility === query.visibility : pin.visibility !== 'private')); - return filterPinsByRadius(pins, query) + const scopedPins = hasGeoPoint(query) + ? filterPinsByRadius(pins, query) + : pins.filter((pin) => pin.userId === userId); + + recordMockCommunityRecommendationEvent(userId, 'sound_map_viewed', { + hasLocation: hasGeoPoint(query), + radiusMeters: query.radiusMeters, + visibility: query.visibility, + }); + + return scopedPins .map((pin) => soundMapPinToDto(pin, userId)); }, @@ -985,6 +1207,14 @@ export const mockSoundlogService = { radiusMeters?: number; state?: string; }) { + if (!hasGeoPoint(query)) { + recordMockCommunityRecommendationEvent(userId, 'nearby_sound_opened', { + hasLocation: false, + radiusMeters: query.radiusMeters, + }); + return []; + } + const blockedIds = mockDb.communityBlocks .filter((block) => block.blockerId === userId) .map((block) => block.blockedUserId); @@ -993,6 +1223,13 @@ export const mockSoundlogService = { .filter((pin) => pin.userId !== userId && pin.visibility === 'nearby') .filter((pin) => !blockedIds.includes(pin.userId)); + recordMockCommunityRecommendationEvent(userId, 'nearby_sound_opened', { + hasLocation: true, + mood: query.mood, + radiusMeters: query.radiusMeters, + state: query.state, + }); + return filterPinsByRadius(pins, query) .map((pin) => ({ ...soundMapPinToDto(pin, userId), @@ -1001,8 +1238,20 @@ export const mockSoundlogService = { })); }, - async getMusicMatches(userId: string, query: { mood?: string; state?: string }) { + async getMusicMatches(userId: string, query: { + lat?: number; + lng?: number; + mood?: string; + radiusMeters?: number; + state?: string; + }) { const pins = await this.getNearbySoundMatches(userId, query); + recordMockCommunityRecommendationEvent(userId, 'music_match_viewed', { + hasLocation: hasGeoPoint(query), + mood: query.mood, + radiusMeters: query.radiusMeters, + state: query.state, + }); return pins.map((pin) => ({ id: `match-${pin.id}`, pin, @@ -1028,6 +1277,12 @@ export const mockSoundlogService = { if (!targetUserId) { throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); } + if (targetUserId === userId) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_SELF_REQUEST_NOT_ALLOWED); + } + if (targetPin && (targetPin.visibility !== 'nearby' || targetPin.expiresAt <= new Date())) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); + } const existingActiveRequest = mockDb.travelMateRequests .filter( (request) => @@ -1054,6 +1309,11 @@ export const mockSoundlogService = { updatedAt: now, }; mockDb.travelMateRequests.push(request); + recordMockCommunityRecommendationEvent(userId, 'travel_mate_requested', { + requestId: request.id, + targetPinId: request.targetPinId, + targetUserId: request.targetUserId, + }, { sessionId: request.targetPinId ?? request.id }); return mateRequestToDto(request); }, @@ -1064,6 +1324,19 @@ export const mockSoundlogService = { if (!request) { throw notFound(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_NOT_FOUND); } + + if (['accept', 'decline'].includes(input.action) && request.targetUserId !== userId) { + throw forbidden(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_ACTION_NOT_ALLOWED); + } + + if (input.action === 'cancel' && request.requesterId !== userId) { + throw forbidden(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_ACTION_NOT_ALLOWED); + } + + if (request.status !== 'pending' && input.action !== 'expire') { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_NOT_PENDING); + } + const nextStatusByAction: Record = { accept: 'accepted', cancel: 'cancelled', @@ -1072,6 +1345,11 @@ export const mockSoundlogService = { }; request.status = nextStatusByAction[input.action] ?? request.status; request.updatedAt = new Date(); + recordMockCommunityRecommendationEvent(userId, `travel_mate_${request.status}`, { + requestId: request.id, + targetPinId: request.targetPinId, + targetUserId: request.targetUserId, + }, { sessionId: request.targetPinId ?? request.id }); return mateRequestToDto(request); }, @@ -1085,6 +1363,10 @@ export const mockSoundlogService = { throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); } + if (targetUserId === userId) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_SELF_REQUEST_NOT_ALLOWED); + } + if (!mockDb.communityBlocks.some((block) => block.blockerId === userId && block.blockedUserId === targetUserId)) { mockDb.communityBlocks.push({ id: createPublicId('block'), @@ -1093,6 +1375,11 @@ export const mockSoundlogService = { createdAt: new Date(), }); } + + recordMockCommunityRecommendationEvent(userId, 'community_user_blocked', { + targetPinId: input.targetPinId, + targetUserId, + }, { sessionId: input.targetPinId ?? targetUserId }); }, async reportCommunityTarget(userId: string, input: { @@ -1112,6 +1399,12 @@ export const mockSoundlogService = { targetUserId: input.targetUserId, createdAt: new Date(), }); + recordMockCommunityRecommendationEvent(userId, 'community_user_reported', { + reason: input.reason, + requestId: input.requestId, + targetPinId: input.targetPinId, + targetUserId: input.targetUserId, + }, { sessionId: input.requestId ?? input.targetPinId ?? input.targetUserId ?? 'community' }); }, async getRecaps(_userId: string, params: { cursor?: string; limit?: number }) { @@ -1220,7 +1513,7 @@ export const mockSoundlogService = { }); }, - async createTravelSession(_userId: string, input: { + async createTravelSession(userId: string, input: { location?: { lat: number; lng: number }; startedAt?: string; travelMode?: string; @@ -1232,6 +1525,7 @@ export const mockSoundlogService = { travelMode: input.travelMode, lat: input.location?.lat, lng: input.location?.lng, + userId, }; mockDb.travelSessions.push(session); @@ -1244,12 +1538,14 @@ export const mockSoundlogService = { }; }, - async updateTravelSession(_userId: string, sessionId: string, input: { + async updateTravelSession(userId: string, sessionId: string, input: { endedAt?: string; location?: { lat: number; lng: number }; status: 'active' | 'ended'; }) { - const session = mockDb.travelSessions.find((item) => item.id === sessionId); + const session = mockDb.travelSessions.find( + (item) => item.id === sessionId && item.userId === userId, + ); if (!session) { throw notFound(ERROR_MESSAGES.TRAVEL_SESSION_NOT_FOUND); diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index 5aed3b7..ed7fdc0 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -14,6 +14,7 @@ import { type TravelRoom, type TravelRoomMember, type TravelRoomMoment, + type TravelRoomMomentComment, type TravelSession, type UserProfile, } from '@prisma/client'; @@ -24,7 +25,7 @@ import { ERROR_MESSAGES } from '../constants/error.constants.js'; import { prisma } from '../config/prisma.js'; import { getLimit, paginateByCursor } from '../utils/pagination.js'; import { createPublicId } from '../utils/tokens.js'; -import { badRequest, notFound } from '../utils/http-error.js'; +import { badRequest, forbidden, notFound } from '../utils/http-error.js'; type MaybeUser = { id: string } | undefined; @@ -44,7 +45,7 @@ type RecommendationContext = Record; type CommunityVisibility = 'companions' | 'nearby' | 'private'; type RoomWithCommunity = TravelRoom & { members: TravelRoomMember[]; - moments: TravelRoomMoment[]; + moments: Array; }; type SoundMapPinWithUser = SoundMapPin & { user: { @@ -131,6 +132,10 @@ function filterPinsByRadius( ); } +function hasGeoPoint(query: { lat?: number; lng?: number }) { + return query.lat !== undefined && query.lng !== undefined; +} + function createInviteCode() { return crypto.randomBytes(4).toString('hex').toUpperCase(); } @@ -185,6 +190,13 @@ function roomToDto(room: RoomWithCommunity) { note: moment.note ?? undefined, status: moment.status, track: (moment.trackSnapshot as TrackDto | null) ?? undefined, + commentCount: moment.comments?.length ?? 0, + comments: moment.comments?.map((comment) => ({ + id: comment.id, + userId: comment.userId, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + })) ?? [], createdAt: moment.createdAt.toISOString(), })), createdAt: room.createdAt.toISOString(), @@ -195,10 +207,16 @@ function roomToDto(room: RoomWithCommunity) { function soundMapPinToDto(pin: SoundMapPinWithUser, viewerId: string, includeExactLocation = false) { const isMine = pin.userId === viewerId; const track = (pin.trackSnapshot as TrackDto | null) ?? undefined; + const alias = isMine + ? '나' + : pin.visibility === 'nearby' + ? '근처 여행자' + : pin.user.displayName ?? '동행자'; + return { id: pin.id, userId: isMine ? pin.userId : undefined, - alias: isMine ? '나' : pin.user.displayName ?? '근처 여행자', + alias, isMine, visibility: pin.visibility, location: includeExactLocation || isMine @@ -219,7 +237,12 @@ function soundMapPinToDto(pin: SoundMapPinWithUser, viewerId: string, includeExa }; } -function scoreMatch(pin: SoundMapPinWithUser, params: { mood?: string; state?: string }) { +function scoreMatch(pin: SoundMapPinWithUser, params: { + lat?: number; + lng?: number; + mood?: string; + state?: string; +}) { const profile = pin.user.profile; let score = 64; if (params.mood && profile?.preferredMoods.some((mood) => params.mood?.includes(mood))) { @@ -234,9 +257,50 @@ function scoreMatch(pin: SoundMapPinWithUser, params: { mood?: string; state?: s if (pin.trackSnapshot) { score += 6; } + + if (params.lat !== undefined && params.lng !== undefined) { + const distance = distanceMeters({ lat: params.lat, lng: params.lng }, pin); + if (distance <= 500) { + score += 8; + } else if (distance <= 1500) { + score += 4; + } + } + + const minutesSinceUpdate = (Date.now() - pin.updatedAt.getTime()) / 60_000; + if (minutesSinceUpdate <= 30) { + score += 6; + } else if (minutesSinceUpdate <= 120) { + score += 3; + } + return Math.min(score, 96); } +async function recordCommunityRecommendationEvent( + userId: string, + type: string, + context: RecommendationContext, + input?: { + sessionId?: string; + trackId?: string; + value?: string; + }, +) { + await prisma.recommendationEvent.create({ + data: { + id: createPublicId('event'), + userId, + sessionId: input?.sessionId ?? String(context.sessionId ?? context.roomId ?? 'community'), + type, + trackId: input?.trackId, + value: input?.value, + context: context as Prisma.InputJsonValue, + createdAt: new Date(), + }, + }); +} + function mateRequestToDto(request: TravelMateRequest) { return { id: request.id, @@ -1391,10 +1455,18 @@ export const soundlogService = { }, include: { members: true, - moments: { orderBy: { createdAt: 'desc' } }, + moments: { + include: { comments: { orderBy: { createdAt: 'asc' } } }, + orderBy: { createdAt: 'desc' }, + }, }, }); + await recordCommunityRecommendationEvent(userId, 'trip_room_created', { + roomId: room.id, + visibility: room.visibility, + }, { sessionId: room.sessionId ?? room.id }); + return roomToDto(room); }, @@ -1406,7 +1478,10 @@ export const soundlogService = { }, include: { members: true, - moments: { orderBy: { createdAt: 'desc' } }, + moments: { + include: { comments: { orderBy: { createdAt: 'asc' } } }, + orderBy: { createdAt: 'desc' }, + }, }, }); @@ -1425,7 +1500,10 @@ export const soundlogService = { where: { id: roomId }, include: { members: true, - moments: { orderBy: { createdAt: 'desc' } }, + moments: { + include: { comments: { orderBy: { createdAt: 'asc' } } }, + orderBy: { createdAt: 'desc' }, + }, }, }); @@ -1433,7 +1511,13 @@ export const soundlogService = { throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); } - if (input.inviteCode && input.inviteCode !== room.inviteCode) { + const existingMember = room.members.find((member) => member.userId === userId); + + if (!existingMember && input.inviteCode !== room.inviteCode) { + throw badRequest(ERROR_MESSAGES.TRAVEL_ROOM_INVITE_CODE_INVALID); + } + + if (existingMember && input.inviteCode && input.inviteCode !== room.inviteCode) { throw badRequest(ERROR_MESSAGES.TRAVEL_ROOM_INVITE_CODE_INVALID); } @@ -1455,6 +1539,11 @@ export const soundlogService = { }, }); + await recordCommunityRecommendationEvent(userId, 'trip_room_joined', { + roomId, + role: existingMember?.role ?? 'member', + }, { sessionId: room.sessionId ?? roomId }); + return this.getTravelRoom(userId, roomId); }, @@ -1483,6 +1572,11 @@ export const soundlogService = { const momentLog = input.momentLogId ? await prisma.momentLog.findFirst({ where: { id: input.momentLogId, userId } }) : undefined; + + if (input.momentLogId && !momentLog) { + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + const track = input.trackId ? await prisma.track.findUnique({ where: { id: input.trackId } }) : undefined; const trackSnapshot = (momentLog?.trackSnapshot as TrackDto | null | undefined) ?? @@ -1505,6 +1599,15 @@ export const soundlogService = { }, }); + await recordCommunityRecommendationEvent(userId, 'shared_moment_added', { + roomId, + momentId: roomMoment.id, + placeName: roomMoment.placeName, + }, { + sessionId: roomId, + trackId: (roomMoment.trackSnapshot as TrackDto | null)?.id, + }); + return { id: roomMoment.id, userId: roomMoment.userId, @@ -1517,6 +1620,105 @@ export const soundlogService = { }; }, + async updateTravelRoomMoment(userId: string, roomId: string, momentId: string, input: { + status: string; + }) { + const room = await prisma.travelRoom.findFirst({ + where: { + id: roomId, + ownerId: userId, + }, + }); + + if (!room) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + const moment = await prisma.travelRoomMoment.findFirst({ + where: { id: momentId, roomId }, + }); + + if (!moment) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_MOMENT_NOT_FOUND); + } + + const updated = await prisma.travelRoomMoment.update({ + where: { id: moment.id }, + data: { status: input.status }, + include: { comments: { orderBy: { createdAt: 'asc' } } }, + }); + + await recordCommunityRecommendationEvent(userId, 'shared_moment_status_updated', { + roomId, + momentId, + status: input.status, + }, { sessionId: room.sessionId ?? roomId }); + + return { + id: updated.id, + userId: updated.userId, + momentLogId: updated.momentLogId ?? undefined, + note: updated.note ?? undefined, + placeName: updated.placeName ?? undefined, + status: updated.status, + track: (updated.trackSnapshot as TrackDto | null) ?? undefined, + commentCount: updated.comments.length, + comments: updated.comments.map((comment) => ({ + id: comment.id, + userId: comment.userId, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + })), + createdAt: updated.createdAt.toISOString(), + }; + }, + + async addTravelRoomMomentComment(userId: string, roomId: string, momentId: string, input: { + body: string; + }) { + const member = await prisma.travelRoomMember.findUnique({ + where: { + roomId_userId: { + roomId, + userId, + }, + }, + }); + + if (!member) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); + } + + const moment = await prisma.travelRoomMoment.findFirst({ + where: { id: momentId, roomId }, + include: { room: true }, + }); + + if (!moment) { + throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_MOMENT_NOT_FOUND); + } + + const comment = await prisma.travelRoomMomentComment.create({ + data: { + body: input.body, + momentId: moment.id, + userId, + }, + }); + + await recordCommunityRecommendationEvent(userId, 'shared_moment_commented', { + roomId, + momentId, + }, { sessionId: moment.room.sessionId ?? roomId }); + + return { + id: comment.id, + userId: comment.userId, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + }; + }, + async createTravelRoomRecap( userId: string, roomId: string, @@ -1544,7 +1746,10 @@ export const soundlogService = { throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); } - const momentTracks = room.moments + const recapMoments = room.moments.some((moment) => moment.status === 'accepted') + ? room.moments.filter((moment) => moment.status === 'accepted') + : room.moments; + const momentTracks = recapMoments .map((moment) => (moment.trackSnapshot as TrackDto | null)?.id) .filter((trackId): trackId is string => Boolean(trackId)); const representativeTrackId = input.representativeTrackId ?? momentTracks[0] ?? 'seoul-city'; @@ -1559,12 +1764,12 @@ export const soundlogService = { id: createPublicId('recap'), userId, title: input.title ?? `${room.title} 공동 Recap`, - placeName: room.moments[0]?.placeName ?? room.title, + placeName: recapMoments[0]?.placeName ?? room.title, representativeTrackId: track.id, - momentCount: room.moments.length, + momentCount: recapMoments.length, sessionId: room.sessionId, - recordedAt: room.moments[0]?.createdAt ?? new Date(), - moments: room.moments.map((moment) => { + recordedAt: recapMoments[0]?.createdAt ?? new Date(), + moments: recapMoments.map((moment) => { const momentTrack = (moment.trackSnapshot as TrackDto | null) ?? undefined; return { id: moment.id, @@ -1578,6 +1783,15 @@ export const soundlogService = { include: { representativeTrack: true }, }); + await recordCommunityRecommendationEvent(userId, 'collab_recap_created', { + roomId, + recapId: recap.id, + templateId: input.templateId, + }, { + sessionId: room.sessionId ?? roomId, + trackId: track.id, + }); + return compact({ ...recapItemToDto(recap), roomId, @@ -1599,6 +1813,22 @@ export const soundlogService = { ttlMinutes?: number; visibility: CommunityVisibility; }) { + if (!input.sessionId) { + throw badRequest(ERROR_MESSAGES.TRAVEL_SESSION_ACTIVE_REQUIRED); + } + + const session = await prisma.travelSession.findFirst({ + where: { + id: input.sessionId, + status: 'active', + userId, + }, + }); + + if (!session) { + throw badRequest(ERROR_MESSAGES.TRAVEL_SESSION_ACTIVE_REQUIRED); + } + const track = input.trackId ? await prisma.track.findUnique({ where: { id: input.trackId } }) : undefined; const trackSnapshot = createTrackSnapshot(track, { artistName: input.artistName, @@ -1616,9 +1846,9 @@ export const soundlogService = { lng: input.location.lng, moodTags: input.moodTags ?? [], placeName: input.placeName, - sessionId: input.sessionId, + sessionId: session.id, trackSnapshot: trackSnapshot ? toInputJson(trackSnapshot) : undefined, - travelMode: input.travelMode, + travelMode: input.travelMode ?? session.travelMode, visibility: input.visibility, }, create: { @@ -1630,9 +1860,9 @@ export const soundlogService = { lng: input.location.lng, moodTags: input.moodTags ?? [], placeName: input.placeName, - sessionId: input.sessionId, + sessionId: session.id, trackSnapshot: trackSnapshot ? toInputJson(trackSnapshot) : undefined, - travelMode: input.travelMode, + travelMode: input.travelMode ?? session.travelMode, userId, visibility: input.visibility, }, @@ -1652,6 +1882,15 @@ export const soundlogService = { }, }); + await recordCommunityRecommendationEvent(userId, 'live_track_shared', { + placeName: input.placeName, + visibility: input.visibility, + }, { + sessionId: session.id, + trackId: trackSnapshot?.id, + value: input.visibility, + }); + return soundMapPinToDto(pin, userId, input.visibility !== 'nearby'); }, @@ -1667,9 +1906,13 @@ export const soundlogService = { }); const pins = await prisma.soundMapPin.findMany({ where: { - expiresAt: { gt: new Date() }, - userId: { notIn: blockedUsers.map((block) => block.blockedUserId) }, - visibility: query.visibility ?? { not: 'private' }, + AND: [ + { expiresAt: { gt: new Date() } }, + { userId: { notIn: blockedUsers.map((block) => block.blockedUserId) } }, + query.visibility + ? { visibility: query.visibility } + : { OR: [{ userId }, { visibility: { not: 'private' } }] }, + ], }, include: { user: { @@ -1689,9 +1932,17 @@ export const soundlogService = { take: 50, }); - return filterPinsByRadius(pins, query).map((pin) => - soundMapPinToDto(pin, userId, pin.visibility !== 'nearby'), - ); + const scopedPins = hasGeoPoint(query) + ? filterPinsByRadius(pins, query) + : pins.filter((pin) => pin.userId === userId); + + await recordCommunityRecommendationEvent(userId, 'sound_map_viewed', { + hasLocation: hasGeoPoint(query), + radiusMeters: query.radiusMeters, + visibility: query.visibility, + }); + + return scopedPins.map((pin) => soundMapPinToDto(pin, userId, false)); }, async getNearbySoundMatches(userId: string, query: { @@ -1701,6 +1952,14 @@ export const soundlogService = { radiusMeters?: number; state?: string; }) { + if (!hasGeoPoint(query)) { + await recordCommunityRecommendationEvent(userId, 'nearby_sound_opened', { + hasLocation: false, + radiusMeters: query.radiusMeters, + }); + return []; + } + const blockedUsers = await prisma.communityBlock.findMany({ select: { blockedUserId: true }, where: { blockerId: userId }, @@ -1732,6 +1991,13 @@ export const soundlogService = { take: 20, }); + await recordCommunityRecommendationEvent(userId, 'nearby_sound_opened', { + hasLocation: true, + mood: query.mood, + radiusMeters: query.radiusMeters, + state: query.state, + }); + return filterPinsByRadius(pins, query).map((pin) => ({ ...soundMapPinToDto(pin, userId, false), matchScore: scoreMatch(pin, query), @@ -1748,6 +2014,13 @@ export const soundlogService = { }) { const pins = await this.getNearbySoundMatches(userId, query); + await recordCommunityRecommendationEvent(userId, 'music_match_viewed', { + hasLocation: hasGeoPoint(query), + mood: query.mood, + radiusMeters: query.radiusMeters, + state: query.state, + }); + return pins .map((pin) => ({ id: `match-${pin.id}`, @@ -1777,6 +2050,14 @@ export const soundlogService = { throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); } + if (targetUserId === userId) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_SELF_REQUEST_NOT_ALLOWED); + } + + if (targetPin && (targetPin.visibility !== 'nearby' || targetPin.expiresAt <= new Date())) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); + } + const existingActiveRequest = await prisma.travelMateRequest.findFirst({ where: { requesterId: userId, @@ -1802,21 +2083,35 @@ export const soundlogService = { }, }); + await recordCommunityRecommendationEvent(userId, 'travel_mate_requested', { + requestId: request.id, + targetPinId: request.targetPinId, + targetUserId: request.targetUserId, + }, { sessionId: request.targetPinId ?? request.id }); + return mateRequestToDto(request); }, async updateTravelMateRequest(userId: string, requestId: string, input: { action: string }) { - const request = await prisma.travelMateRequest.findFirst({ - where: { - id: requestId, - OR: [{ requesterId: userId }, { targetUserId: userId }], - }, - }); + const request = await prisma.travelMateRequest.findUnique({ where: { id: requestId } }); - if (!request) { + if (!request || (request.requesterId !== userId && request.targetUserId !== userId)) { throw notFound(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_NOT_FOUND); } + const targetOnlyActions = new Set(['accept', 'decline']); + if (targetOnlyActions.has(input.action) && request.targetUserId !== userId) { + throw forbidden(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_ACTION_NOT_ALLOWED); + } + + if (input.action === 'cancel' && request.requesterId !== userId) { + throw forbidden(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_ACTION_NOT_ALLOWED); + } + + if (request.status !== 'pending' && input.action !== 'expire') { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_REQUEST_NOT_PENDING); + } + const nextStatusByAction: Record = { accept: 'accepted', cancel: 'cancelled', @@ -1828,6 +2123,12 @@ export const soundlogService = { data: { status: nextStatusByAction[input.action] ?? request.status }, }); + await recordCommunityRecommendationEvent(userId, `travel_mate_${updated.status}`, { + requestId: updated.id, + targetPinId: updated.targetPinId, + targetUserId: updated.targetUserId, + }, { sessionId: updated.targetPinId ?? updated.id }); + return mateRequestToDto(updated); }, @@ -1841,6 +2142,10 @@ export const soundlogService = { throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); } + if (targetUserId === userId) { + throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_SELF_REQUEST_NOT_ALLOWED); + } + await prisma.communityBlock.upsert({ where: { blockerId_blockedUserId: { @@ -1854,6 +2159,11 @@ export const soundlogService = { blockerId: userId, }, }); + + await recordCommunityRecommendationEvent(userId, 'community_user_blocked', { + targetPinId: input.targetPinId, + targetUserId, + }, { sessionId: input.targetPinId ?? targetUserId }); }, async reportCommunityTarget(userId: string, input: { @@ -1873,6 +2183,13 @@ export const soundlogService = { targetUserId: input.targetUserId, }, }); + + await recordCommunityRecommendationEvent(userId, 'community_user_reported', { + reason: input.reason, + requestId: input.requestId, + targetPinId: input.targetPinId, + targetUserId: input.targetUserId, + }, { sessionId: input.requestId ?? input.targetPinId ?? input.targetUserId ?? 'community' }); }, async getRecaps(userId: string, params: { cursor?: string; limit?: number }) { diff --git a/src/utils/http-error.ts b/src/utils/http-error.ts index 5727060..ee329a3 100644 --- a/src/utils/http-error.ts +++ b/src/utils/http-error.ts @@ -15,6 +15,10 @@ export function badRequest(message: string, details?: Record) { return new HttpError(400, ERROR_CODES.BAD_REQUEST, message, details); } +export function forbidden(message: string = ERROR_MESSAGES.FORBIDDEN) { + return new HttpError(403, ERROR_CODES.FORBIDDEN, message); +} + export function unauthorized(message: string = ERROR_MESSAGES.AUTH_REQUIRED) { return new HttpError(401, ERROR_CODES.UNAUTHORIZED, message); } diff --git a/src/validators/api.validators.ts b/src/validators/api.validators.ts index 5741cff..68727fd 100644 --- a/src/validators/api.validators.ts +++ b/src/validators/api.validators.ts @@ -261,6 +261,23 @@ export const recommendationEventValidators = { 'mood_filter_change', 'live_track_shared', 'nearby_sound_opened', + 'sound_map_viewed', + 'music_match_viewed', + 'music_match_profile_opened', + 'travel_mode_enabled', + 'trip_room_created', + 'trip_room_joined', + 'shared_moment_added', + 'shared_moment_status_updated', + 'shared_moment_commented', + 'collab_recap_created', + 'travel_mate_requested', + 'travel_mate_accepted', + 'travel_mate_declined', + 'travel_mate_cancelled', + 'travel_mate_expired', + 'community_user_blocked', + 'community_user_reported', 'recommendation_mode_change', 'top_filter_change', 'recap_representative_track_select', @@ -317,6 +334,10 @@ export const communityValidators = { roomParams: z.object({ roomId: z.string().min(1), }), + roomMomentParams: z.object({ + momentId: z.string().min(1), + roomId: z.string().min(1), + }), createRoomBody: z.object({ sessionId: z.string().optional(), title: z.string().trim().min(1).max(80).default('Soundlog 여행방'), @@ -331,10 +352,16 @@ export const communityValidators = { momentLogId: z.string().optional(), note: z.string().trim().max(240).optional(), placeName: z.string().trim().max(120).optional(), - status: z.enum(['candidate', 'accepted']).optional().default('candidate'), + status: z.enum(['candidate', 'accepted', 'rejected']).optional().default('candidate'), trackId: z.string().optional(), trackTitle: z.string().trim().max(160).optional(), }), + updateRoomMomentBody: z.object({ + status: z.enum(['candidate', 'accepted', 'rejected']), + }), + addRoomMomentCommentBody: z.object({ + body: z.string().trim().min(1).max(300), + }), createRoomRecapBody: z.object({ representativeTrackId: z.string().optional(), templateId: z.enum(['album', 'film', 'lp']).optional().default('album'), diff --git a/tests/api.test.ts b/tests/api.test.ts index 28400d8..0615862 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -532,16 +532,42 @@ describe('Soundlog API', () => { expect(targetLogin.status).toBe(200); const targetAuthHeader = `Bearer ${targetLogin.body.data.accessToken}`; + const ownerSession = await request(app) + .post('/v1/travel-sessions') + .set('Authorization', authHeader) + .send({ + location: { lat: 37.751, lng: 128.875 }, + travelMode: 'walk', + }); + expect(ownerSession.status).toBe(201); + + const targetSession = await request(app) + .post('/v1/travel-sessions') + .set('Authorization', targetAuthHeader) + .send({ + location: { lat: 37.752, lng: 128.876 }, + travelMode: 'walk', + }); + expect(targetSession.status).toBe(201); + const room = await request(app) .post('/v1/travel-rooms') .set('Authorization', authHeader) .send({ - sessionId: 'community-session', + sessionId: ownerSession.body.data.id, title: '강릉 사운드 여행방', }); expect(room.status).toBe(201); expect(room.body.data.inviteCode).toEqual(expect.any(String)); + const missingInvite = await request(app) + .post(`/v1/travel-rooms/${room.body.data.id}/join`) + .set('Authorization', targetAuthHeader) + .send({ + displayName: '수경', + }); + expect(missingInvite.status).toBe(400); + const joined = await request(app) .post(`/v1/travel-rooms/${room.body.data.id}/join`) .set('Authorization', targetAuthHeader) @@ -552,6 +578,15 @@ describe('Soundlog API', () => { expect(joined.status).toBe(200); expect(joined.body.data.memberCount).toBe(2); + const invalidMomentLog = await request(app) + .post(`/v1/travel-rooms/${room.body.data.id}/moments`) + .set('Authorization', targetAuthHeader) + .send({ + momentLogId: 'missing-moment-log', + note: '잘못된 Moment 참조', + }); + expect(invalidMomentLog.status).toBe(404); + const sharedMoment = await request(app) .post(`/v1/travel-rooms/${room.body.data.id}/moments`) .set('Authorization', targetAuthHeader) @@ -564,6 +599,20 @@ describe('Soundlog API', () => { expect(sharedMoment.status).toBe(201); expect(sharedMoment.body.data.track.id).toBe('seoul-night-track'); + const updatedMoment = await request(app) + .patch(`/v1/travel-rooms/${room.body.data.id}/moments/${sharedMoment.body.data.id}`) + .set('Authorization', authHeader) + .send({ status: 'accepted' }); + expect(updatedMoment.status).toBe(200); + expect(updatedMoment.body.data.status).toBe('accepted'); + + const commentedMoment = await request(app) + .post(`/v1/travel-rooms/${room.body.data.id}/moments/${sharedMoment.body.data.id}/comments`) + .set('Authorization', authHeader) + .send({ body: '이 컷은 첫 번째 페이지에 넣자' }); + expect(commentedMoment.status).toBe(201); + expect(commentedMoment.body.data.body).toContain('첫 번째'); + const roomRecap = await request(app) .post(`/v1/travel-rooms/${room.body.data.id}/recaps`) .set('Authorization', authHeader) @@ -581,7 +630,7 @@ describe('Soundlog API', () => { location: { lat: 37.752, lng: 128.876 }, moodTags: ['calm'], placeName: '강릉 카페거리', - sessionId: 'community-session', + sessionId: targetSession.body.data.id, trackId: 'seoul-night-track', travelMode: 'walk', visibility: 'nearby', @@ -596,7 +645,7 @@ describe('Soundlog API', () => { location: { lat: 37.751, lng: 128.875 }, moodTags: ['fresh'], placeName: '강릉역', - sessionId: 'community-session', + sessionId: ownerSession.body.data.id, trackId: 'seoul-city', travelMode: 'walk', visibility: 'companions', @@ -626,17 +675,25 @@ describe('Soundlog API', () => { expect(emptyNearbyByRadius.status).toBe(200); expect(emptyNearbyByRadius.body.data).toEqual([]); - const nearby = await request(app) + const emptyNearbyWithoutLocation = await request(app) .get('/v1/sound-map/nearby') .set('Authorization', authHeader) .query({ mood: '잔잔한', state: '산책' }); + expect(emptyNearbyWithoutLocation.status).toBe(200); + expect(emptyNearbyWithoutLocation.body.data).toEqual([]); + + const nearby = await request(app) + .get('/v1/sound-map/nearby') + .set('Authorization', authHeader) + .query({ lat: 37.751, lng: 128.875, mood: '잔잔한', state: '산책' }); expect(nearby.status).toBe(200); expect(nearby.body.data[0].targetPinId).toBe(targetPin.body.data.id); + expect(nearby.body.data[0].alias).toBe('근처 여행자'); const matches = await request(app) .get('/v1/music-matches') .set('Authorization', authHeader) - .query({ mood: '잔잔한', state: '산책' }); + .query({ lat: 37.751, lng: 128.875, mood: '잔잔한', state: '산책' }); expect(matches.status).toBe(200); expect(matches.body.data[0].targetPinId).toBe(targetPin.body.data.id); expect(matches.body.data[0].safety.exactLocationHidden).toBe(true); @@ -651,6 +708,12 @@ describe('Soundlog API', () => { expect(mateRequest.status).toBe(201); expect(mateRequest.body.data.status).toBe('pending'); + const requesterAccept = await request(app) + .patch(`/v1/travel-mate-requests/${mateRequest.body.data.id}`) + .set('Authorization', authHeader) + .send({ action: 'accept' }); + expect(requesterAccept.status).toBe(403); + const duplicateMateRequest = await request(app) .post('/v1/travel-mate-requests') .set('Authorization', authHeader) @@ -687,7 +750,7 @@ describe('Soundlog API', () => { const hiddenMatches = await request(app) .get('/v1/music-matches') .set('Authorization', authHeader) - .query({ mood: '잔잔한', state: '산책' }); + .query({ lat: 37.751, lng: 128.875, mood: '잔잔한', state: '산책' }); expect(hiddenMatches.status).toBe(200); expect(hiddenMatches.body.data).toEqual([]); });