From 2d85f9d6d462efff86019a598956057f5b40b867 Mon Sep 17 00:00:00 2001 From: manNomi Date: Thu, 9 Jul 2026 18:32:41 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=EB=A6=AC=EC=BA=A1=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=9C=84=EC=B9=98=20=EA=B3=84=EC=95=BD=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 11 ++++++++++ prisma/seed.ts | 2 ++ src/data/seed-data.ts | 8 ++++++++ src/mock/mock-db.ts | 2 ++ src/services/mock-soundlog.service.ts | 4 ++++ src/services/soundlog.service.ts | 29 +++++++++++++++++---------- tests/api.test.ts | 24 ++++++++++++++++++++-- tests/mock-soundlog.service.test.ts | 6 ++++++ 8 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f645175 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Soundlog Server Agent Rules + +- Do not merge GitHub pull requests unless the user's latest message explicitly asks to merge that exact PR. +- PR creation, CI verification, reviewer assignment, issue closing, or "finish the task" does not imply merge approval. +- If a merge request is ambiguous, ask for confirmation before using any merge UI, GitHub API, or commands such as `gh pr merge`. + +## Product Platform Direction + +- Soundlog is a mobile app product. We do not intend to ship or maintain a public web deployment as a product surface. +- Server API contracts should prioritize the iOS/Android app experience. Do not add web-specific API behavior unless the user explicitly asks for web support. +- Web export or browser checks from the app repository are compatibility checks, not product deployment requirements. diff --git a/prisma/seed.ts b/prisma/seed.ts index e5d033d..ba1604f 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -207,6 +207,8 @@ export async function seedDatabase() { userId: user.id, photoUrl: log.photoUrl, createdAt: new Date(log.createdAt), + lat: log.lat, + lng: log.lng, sessionId: log.sessionId, placeName: log.placeName, note: log.note, diff --git a/src/data/seed-data.ts b/src/data/seed-data.ts index 2233124..eb4b2ca 100644 --- a/src/data/seed-data.ts +++ b/src/data/seed-data.ts @@ -211,6 +211,8 @@ export const seedMomentLogs = [ id: 'log-1', photoUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', createdAt: '2026-05-25T00:00:00.000Z', + lat: 35.1532, + lng: 129.1187, placeName: '광안리', note: '바다 앞에서 처음 저장한 사운드', trackId: 'seoul-city', @@ -221,6 +223,8 @@ export const seedMomentLogs = [ id: 'log-2', photoUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', createdAt: '2026-05-25T00:10:00.000Z', + lat: 37.5294, + lng: 126.9348, placeName: '한강', note: '강변 산책에 어울린 잔잔한 곡', trackId: 'night-letter', @@ -231,6 +235,8 @@ export const seedMomentLogs = [ id: 'log-3', photoUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', createdAt: '2026-05-25T00:20:00.000Z', + lat: 35.1796, + lng: 129.0756, placeName: '부산', note: '여행 에너지가 올라간 순간', trackId: 'seoul-night-track', @@ -255,6 +261,7 @@ export const recaps = [ { id: 'log-1', imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + location: { lat: 35.1532, lng: 129.1187 }, placeName: '광안리', recordedAt: '2026-05-25T00:00:00.000Z', trackTitle: 'Seoul City', @@ -277,6 +284,7 @@ export const recaps = [ { id: 'log-1', imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + location: { lat: 35.1532, lng: 129.1187 }, placeName: '광안리', recordedAt: '2026-05-25T00:00:00.000Z', trackTitle: 'Seoul City', diff --git a/src/mock/mock-db.ts b/src/mock/mock-db.ts index 4d36140..c1b84dd 100644 --- a/src/mock/mock-db.ts +++ b/src/mock/mock-db.ts @@ -229,6 +229,8 @@ function createMockDb() { id: log.id, photoUrl: log.photoUrl, createdAt: new Date(log.createdAt), + lat: log.lat, + lng: log.lng, sessionId: log.sessionId, placeName: log.placeName, note: log.note, diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index 6132f1e..643071d 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -1840,6 +1840,10 @@ export const mockSoundlogService = { moments: moments.map((moment) => ({ id: moment.id, imageUrl: moment.photoUrl, + location: + moment.lat !== undefined && moment.lng !== undefined + ? { lat: moment.lat, lng: moment.lng } + : undefined, placeName: moment.placeName ?? '위치 없음', trackTitle: moment.trackSnapshot?.title ?? '저장된 순간', artistName: moment.trackSnapshot?.artist ?? '음악 없음', diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index 6970454..2df69ef 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -1057,6 +1057,23 @@ function recapShareToDto(recap: Recap & { representativeTrack: Track }) { }); } +function momentLogToRecapShareMoment(moment: MomentLog) { + const momentTrack = (moment.trackSnapshot as TrackDto | null) ?? undefined; + + return compact({ + id: moment.id, + imageUrl: moment.photoUrl, + location: + moment.lat !== null && moment.lng !== null + ? { lat: moment.lat, lng: moment.lng } + : undefined, + placeName: moment.placeName ?? '위치 없음', + trackTitle: momentTrack?.title ?? '저장된 순간', + artistName: momentTrack?.artist ?? '음악 없음', + recordedAt: moment.createdAt.toISOString(), + }); +} + function travelSessionToDto(session: TravelSession) { return compact({ id: session.id, @@ -2780,17 +2797,7 @@ export const soundlogService = { backgroundImageUrl: firstMoment?.photoUrl, discImageUrl: firstMoment?.photoUrl, recordedAt: firstMoment?.createdAt ?? new Date(), - moments: moments.map((moment) => { - const momentTrack = (moment.trackSnapshot as TrackDto | null) ?? undefined; - return { - id: moment.id, - imageUrl: moment.photoUrl, - placeName: moment.placeName ?? '위치 없음', - trackTitle: momentTrack?.title ?? '저장된 순간', - artistName: momentTrack?.artist ?? '음악 없음', - recordedAt: moment.createdAt.toISOString(), - }; - }) as Prisma.JsonArray, + moments: moments.map(momentLogToRecapShareMoment) as Prisma.JsonArray, }, include: { representativeTrack: true }, }); diff --git a/tests/api.test.ts b/tests/api.test.ts index 3338b9f..14443fc 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -74,12 +74,14 @@ function findSecretLeaks(value: unknown, secret: string, path = '$'): string[] { async function createTestMomentLog(input: { authHeader: string; filename: string; + lat?: number; + lng?: number; note?: string; placeName: string; sessionId?: string; trackId?: string; }) { - const response = await request(app) + const requestBuilder = request(app) .post('/v1/moment-logs') .set('Authorization', input.authHeader) .field('createdAt', new Date().toISOString()) @@ -87,7 +89,17 @@ async function createTestMomentLog(input: { .field('note', input.note ?? '') .field('placeName', input.placeName) .field('sessionId', input.sessionId ?? '') - .field('trackId', input.trackId ?? 'seoul-city') + .field('trackId', input.trackId ?? 'seoul-city'); + + if (input.lat !== undefined) { + requestBuilder.field('lat', String(input.lat)); + } + + if (input.lng !== undefined) { + requestBuilder.field('lng', String(input.lng)); + } + + const response = await requestBuilder .attach('photo', Buffer.from('fake-image'), { filename: input.filename, contentType: 'image/jpeg', @@ -659,6 +671,8 @@ describe('Soundlog API', () => { await createTestMomentLog({ authHeader, filename: 'recap-moment-1.jpg', + lat: 37.5512, + lng: 126.9882, placeName: '리캡 테스트 장소', sessionId: recapSessionId, trackId: 'seoul-night-track', @@ -666,6 +680,8 @@ describe('Soundlog API', () => { await createTestMomentLog({ authHeader, filename: 'recap-moment-2.jpg', + lat: 37.552, + lng: 126.989, placeName: '리캡 테스트 장소', sessionId: recapSessionId, trackId: 'seoul-night-track', @@ -700,6 +716,10 @@ describe('Soundlog API', () => { expect(share.body.data.id).toBe(createdRecapId); expect(share.body.data.trackTitle).toBe(created.body.data.representativeTrack.title); expect(share.body.data.moments.length).toBeGreaterThan(1); + expect(share.body.data.moments[0].location).toEqual({ + lat: 37.5512, + lng: 126.9882, + }); const shareEvent = await request(app) .post(`/v1/recaps/${createdRecapId}/share-events`) diff --git a/tests/mock-soundlog.service.test.ts b/tests/mock-soundlog.service.test.ts index 0bdaaee..8c60b37 100644 --- a/tests/mock-soundlog.service.test.ts +++ b/tests/mock-soundlog.service.test.ts @@ -243,6 +243,8 @@ describe('mockSoundlogService', () => { it('creates recaps, share payloads, share events, and validates representative tracks', async () => { const moment = await mockSoundlogService.createMomentLog(ownerId, { createdAt: '2026-07-09T10:00:00.000Z', + lat: 37.5444, + lng: 127.0374, moodTags: ['감성적인'], photoPath: '/uploads/recap.jpg', placeName: '서울숲', @@ -278,6 +280,10 @@ describe('mockSoundlogService', () => { expect(recap.id).toBe(repeated.id); expect(recap.title).toBe('서울숲 사운드'); expect(list.data[0].id).toBe(recapId); + expect((share.moments as Array<{ location?: { lat: number; lng: number } }>)[0].location).toEqual({ + lat: 37.5444, + lng: 127.0374, + }); expect(share.moments).toHaveLength(1); expect(mockDb.recapShareEvents).toHaveLength(1); await expect( From 4507e9c3318fab792a2219411d95dcc6f10af9db Mon Sep 17 00:00:00 2001 From: manNomi Date: Thu, 9 Jul 2026 18:35:12 +0900 Subject: [PATCH 2/6] =?UTF-8?q?test:=20=EB=A6=AC=EC=BA=A1=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EA=B2=80=EC=A6=9D=20=EC=88=9C=EC=84=9C=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/api.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/api.test.ts b/tests/api.test.ts index 14443fc..75c5fc3 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -716,10 +716,12 @@ describe('Soundlog API', () => { expect(share.body.data.id).toBe(createdRecapId); expect(share.body.data.trackTitle).toBe(created.body.data.representativeTrack.title); expect(share.body.data.moments.length).toBeGreaterThan(1); - expect(share.body.data.moments[0].location).toEqual({ - lat: 37.5512, - lng: 126.9882, - }); + expect(share.body.data.moments.map((moment: { location?: unknown }) => moment.location)).toEqual( + expect.arrayContaining([ + { lat: 37.5512, lng: 126.9882 }, + { lat: 37.552, lng: 126.989 }, + ]), + ); const shareEvent = await request(app) .post(`/v1/recaps/${createdRecapId}/share-events`) From 9083804d92a96feeaadaa8540debb264e3580f4b Mon Sep 17 00:00:00 2001 From: manNomi Date: Mon, 13 Jul 2026 01:00:50 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EB=A6=AC=EC=BA=A1=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=EC=99=80=20=EC=97=AC=ED=96=89=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?API=20=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openapi/soundlog-api.yaml | 443 +++++++++++++++++- .../migration.sql | 17 + .../migration.sql | 5 + prisma/models/recap.prisma | 5 + prisma/models/travel.prisma | 1 + prisma/seed.ts | 4 + src/constants/error.constants.ts | 1 + src/controllers/recap.controller.ts | 18 + src/data/seed-data.ts | 8 + src/mock/mock-db.ts | 52 +- src/routes/index.ts | 57 +++ src/services/mock-soundlog.service.ts | 246 +++++++++- src/services/soundlog.service.ts | 320 ++++++++++++- src/validators/api.validators.ts | 23 +- tests/api.test.ts | 283 ++++++++++- tests/mock-soundlog.service.test.ts | 49 ++ 16 files changed, 1482 insertions(+), 50 deletions(-) create mode 100644 prisma/migrations/20260711000000_recap_map_visibility/migration.sql create mode 100644 prisma/migrations/20260712000000_travel_route_points/migration.sql diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index 3430e81..a4c7aa3 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -9,7 +9,7 @@ info: description: | Soundlog React Native 앱과 백엔드 서버를 연결하기 위한 API 명세입니다. - MVP 범위는 홈 추천, 관광지 조회, 플레이리스트 상세, 순간 로그, 리캡, 보관함, 추천 피드백 수집, + MVP 범위는 홈 추천, 관광지 조회, 플레이리스트 상세, 리캡 캡처, 여행 로그, 보관함, 추천 피드백 수집, 여행 세션, 공동 여행방, Live Sound Map, 음악 취향 매칭을 포함합니다. 한국관광공사 OpenAPI 원본 응답은 서버에서 Soundlog 도메인 모델로 정규화해서 앱에 전달합니다. servers: @@ -33,9 +33,11 @@ tags: - name: Playlists description: 장소/무드 기반 플레이리스트 - name: MomentLogs - description: 사진, 장소, 음악, 시간 기반 순간 기록 + description: 레거시 순간 기록 API. 신규 클라이언트는 RecapCaptures를 사용합니다. + - name: RecapCaptures + description: 카메라로 남기는 단발성 리캡 저장 API - name: Recaps - description: 여행 리캡 리스트와 공유 콘텐츠 + description: 여행 로그와 공개/낱개 리캡 리스트 및 공유 콘텐츠 - name: Library description: 좋아요/저장 음악 보관함 - name: RecommendationEvents @@ -652,11 +654,182 @@ paths: "404": $ref: "#/components/responses/NotFound" + /v1/recap-captures: + get: + tags: + - RecapCaptures + summary: 리캡 캡처 목록 조회 + description: 카메라로 저장한 리캡 캡처를 조회합니다. `sessionId`가 있으면 여행 로그에 묶이는 리캡이고, 없으면 낱개 리캡입니다. + operationId: getRecapCaptures + parameters: + - name: sessionId + in: query + schema: + type: string + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: 리캡 캡처 목록 + content: + application/json: + schema: + $ref: "#/components/schemas/MomentLogListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: + - RecapCaptures + summary: 리캡 캡처 생성 + description: | + 카메라 버튼으로 촬영한 사진, 현재 위치, 현재 장소, 재생 중인 음악, 무드 태그를 하나의 리캡으로 저장합니다. + 여행모드 중 `sessionId`가 포함되면 해당 여행 로그에 묶이고, `sessionId`가 없으면 낱개 리캡으로 남습니다. + MVP 저장 정책상 사진, 위치, 장소, 곡은 모두 선택값입니다. 음악이나 위치가 없어도 리캡 캡처는 생성할 수 있습니다. + 사진 업로드는 `multipart/form-data`를 기본으로 정의합니다. + operationId: createRecapCapture + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/MomentLogCreateForm" + responses: + "201": + description: 생성된 리캡 캡처 + content: + application/json: + schema: + $ref: "#/components/schemas/MomentLogResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + + /v1/recap-captures/{momentLogId}: + patch: + tags: + - RecapCaptures + summary: 리캡 캡처 수정 + description: 저장된 리캡 캡처의 메모, 장소, 무드, 곡 스냅샷 같은 편집 가능한 정보를 부분 수정합니다. + operationId: updateRecapCapture + parameters: + - name: momentLogId + in: path + required: true + description: 내부 저장 모델의 MomentLog ID입니다. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MomentLogUpdateRequest" + responses: + "200": + description: 수정된 리캡 캡처 + content: + application/json: + schema: + $ref: "#/components/schemas/MomentLogResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: + - RecapCaptures + summary: 리캡 캡처 삭제 + description: 사용자의 리캡 캡처를 삭제합니다. 공동 여행방 후보가 해당 캡처를 참조하고 있으면 후보 자체는 남기고 참조만 해제합니다. + operationId: deleteRecapCapture + parameters: + - name: momentLogId + in: path + required: true + description: 내부 저장 모델의 MomentLog ID입니다. + schema: + type: string + responses: + "202": + description: 삭제 요청 수락 + content: + application/json: + schema: + $ref: "#/components/schemas/AcceptedResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + + /v1/recap-captures/{momentLogId}/photo: + put: + tags: + - RecapCaptures + summary: 리캡 캡처 사진 교체 + description: 저장된 리캡 캡처의 사진만 새 업로드 파일로 교체합니다. 서버가 관리하는 기존 로컬 업로드 파일은 best-effort로 정리합니다. + operationId: updateRecapCapturePhoto + parameters: + - name: momentLogId + in: path + required: true + description: 내부 저장 모델의 MomentLog ID입니다. + schema: + type: string + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/MomentLogPhotoUpdateForm" + responses: + "200": + description: 사진이 교체된 리캡 캡처 + content: + application/json: + schema: + $ref: "#/components/schemas/MomentLogResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: + - RecapCaptures + summary: 리캡 캡처 사진 삭제 + description: 리캡 캡처 자체는 유지하고 연결된 사진만 제거합니다. 서버가 관리하는 로컬 업로드 파일은 best-effort로 정리합니다. + operationId: deleteRecapCapturePhoto + parameters: + - name: momentLogId + in: path + required: true + description: 내부 저장 모델의 MomentLog ID입니다. + schema: + type: string + responses: + "200": + description: 사진이 제거된 리캡 캡처 + content: + application/json: + schema: + $ref: "#/components/schemas/MomentLogResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /v1/moment-logs: get: tags: - MomentLogs - summary: 순간 로그 목록 조회 + deprecated: true + summary: 레거시 순간 로그 목록 조회 + description: 기존 클라이언트 호환을 위해 유지합니다. 신규 클라이언트는 `GET /v1/recap-captures`를 사용합니다. operationId: getMomentLogs parameters: - name: sessionId @@ -677,8 +850,10 @@ paths: post: tags: - MomentLogs - summary: 순간 로그 생성 + deprecated: true + summary: 레거시 순간 로그 생성 description: | + 기존 클라이언트 호환을 위해 유지합니다. 신규 클라이언트는 `POST /v1/recap-captures`를 사용합니다. 카메라 버튼으로 촬영한 사진, 현재 위치, 현재 장소, 재생 중인 음악, 무드 태그를 하나의 기록으로 저장합니다. MVP 저장 정책상 사진, 위치, 장소, 곡은 모두 선택값입니다. 음악이나 위치가 없어도 MomentLog는 생성할 수 있습니다. 사진 업로드는 `multipart/form-data`를 기본으로 정의합니다. @@ -707,8 +882,10 @@ paths: patch: tags: - MomentLogs - summary: 순간 로그 수정 + deprecated: true + summary: 레거시 순간 로그 수정 description: | + 기존 클라이언트 호환을 위해 유지합니다. 신규 클라이언트는 `PATCH /v1/recap-captures/{momentLogId}`를 사용합니다. 저장된 MomentLog의 메모, 장소, 무드, 곡 스냅샷 같은 편집 가능한 정보를 부분 수정합니다. 사진 파일 교체는 별도 업로드 정책이 필요하므로 이 API에는 포함하지 않습니다. operationId: updateMomentLog @@ -740,8 +917,10 @@ paths: delete: tags: - MomentLogs - summary: 순간 로그 삭제 + deprecated: true + summary: 레거시 순간 로그 삭제 description: | + 기존 클라이언트 호환을 위해 유지합니다. 신규 클라이언트는 `DELETE /v1/recap-captures/{momentLogId}`를 사용합니다. 사용자의 MomentLog를 삭제합니다. 공동 여행방 후보가 해당 로그를 참조하고 있으면 후보 자체는 남기고 MomentLog 참조만 해제합니다. operationId: deleteMomentLog parameters: @@ -766,8 +945,10 @@ paths: put: tags: - MomentLogs - summary: 순간 로그 사진 교체 + deprecated: true + summary: 레거시 순간 로그 사진 교체 description: | + 기존 클라이언트 호환을 위해 유지합니다. 신규 클라이언트는 `PUT /v1/recap-captures/{momentLogId}/photo`를 사용합니다. 저장된 MomentLog의 사진만 새 업로드 파일로 교체합니다. 서버가 관리하는 기존 로컬 업로드 파일은 best-effort로 정리합니다. operationId: updateMomentLogPhoto parameters: @@ -798,8 +979,10 @@ paths: delete: tags: - MomentLogs - summary: 순간 로그 사진 삭제 + deprecated: true + summary: 레거시 순간 로그 사진 삭제 description: | + 기존 클라이언트 호환을 위해 유지합니다. 신규 클라이언트는 `DELETE /v1/recap-captures/{momentLogId}/photo`를 사용합니다. MomentLog 자체는 유지하고 연결된 사진만 제거합니다. 서버가 관리하는 로컬 업로드 파일은 best-effort로 정리합니다. operationId: deleteMomentLogPhoto parameters: @@ -848,16 +1031,75 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + /v1/recap-markers: + get: + tags: + - Recaps + summary: 여행모드 지도 리캡 마커 조회 + description: 현재 위치 기준 300m 이내의 공개 리캡 또는 내 리캡 마커를 조회합니다. `radiusMeters`는 이전 클라이언트 호환을 위해 받을 수 있지만 서버 정책상 항상 300m로 고정됩니다. + operationId: getRecapMarkers + parameters: + - name: lat + in: query + required: false + schema: + type: number + format: double + - name: lng + in: query + required: false + schema: + type: number + format: double + - name: radiusMeters + in: query + deprecated: true + description: 이전 클라이언트 호환용 값입니다. 전달해도 서버는 항상 300m 기준으로 필터링합니다. + required: false + schema: + type: integer + minimum: 50 + maximum: 5000 + default: 300 + - name: scope + in: query + required: false + schema: + type: string + enum: + - public + - mine + default: public + responses: + "200": + description: 지도에 표시할 리캡 마커 목록 + content: + application/json: + schema: + $ref: "#/components/schemas/RecapMapMarkerListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/recaps: get: tags: - Recaps - summary: 리캡 리스트 조회 - description: Recap 탭에서 여행별 리캡을 리스트 형태로 보여줍니다. + summary: 로그/리캡 리스트 조회 + description: 로그 탭에서 여행모드 리캡 묶음인 여행 로그, 여행모드 밖에서 만든 낱개 리캡, 공개 리캡을 리스트 형태로 보여줍니다. `mine`은 내 로그, `others`는 다른 사용자의 전체공개 로그, `all`은 내 로그와 다른 사용자의 전체공개 로그를 함께 반환합니다. operationId: getRecaps parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/Limit" + - name: scope + in: query + required: false + schema: + type: string + enum: + - mine + - others + - all + default: mine responses: "200": description: 리캡 리스트 @@ -870,8 +1112,8 @@ paths: post: tags: - Recaps - summary: 리캡 생성 - description: 여행 세션 또는 순간 로그 묶음을 기반으로 앨범/LP/필름형 리캡을 생성합니다. + summary: 여행 로그 생성 + description: 여행 세션 또는 리캡 캡처 묶음을 기반으로 앨범/LP/필름/지도형 여행 로그를 생성합니다. visibility를 public으로 보내려면 대표 위치가 있는 리캡 캡처가 필요합니다. operationId: createRecap parameters: - $ref: "#/components/parameters/IdempotencyKey" @@ -898,6 +1140,7 @@ paths: tags: - Recaps summary: 공유용 리캡 상세 조회 + description: 내 리캡 또는 전체공개 리캡의 공유/상세 데이터를 조회합니다. private 리캡은 소유자만 조회할 수 있습니다. operationId: getRecapShare parameters: - name: recapId @@ -917,6 +1160,39 @@ paths: "404": $ref: "#/components/responses/NotFound" + /v1/recaps/{recapId}/visibility: + patch: + tags: + - Recaps + summary: 리캡 공개 범위 변경 + description: 리캡을 나만보기 또는 전체공개로 변경합니다. 전체공개 리캡은 대표 위치가 있어야 하며, 위치가 없으면 BAD_REQUEST를 반환합니다. + operationId: updateRecapVisibility + parameters: + - name: recapId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RecapVisibilityUpdateRequest" + responses: + "200": + description: 공개 범위가 변경된 리캡 + content: + application/json: + schema: + $ref: "#/components/schemas/RecapItemResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /v1/recaps/{recapId}/share-events: post: tags: @@ -1849,6 +2125,23 @@ components: format: double example: 129.1186 + RoutePoint: + allOf: + - $ref: "#/components/schemas/GeoPoint" + - type: object + required: + - recordedAt + properties: + recordedAt: + type: string + format: date-time + example: "2026-07-12T04:38:00.000Z" + accuracyMeters: + type: number + format: double + minimum: 0 + example: 12 + TravelMode: type: string description: 여행 상황 모드 @@ -2355,6 +2648,7 @@ components: MomentLogCreateForm: type: object + description: 내부 저장 모델명은 MomentLog지만 외부 계약에서는 리캡 캡처 생성 폼으로 사용합니다. required: - createdAt - moodTags @@ -2362,7 +2656,7 @@ components: photo: type: string format: binary - description: 카메라 촬영 이미지 파일. 사진 없이 음악/장소 로그만 저장할 때는 생략할 수 있습니다. + description: 카메라 촬영 이미지 파일. 사진 없이 음악/장소 리캡만 저장할 때는 생략할 수 있습니다. createdAt: type: string format: date-time @@ -2381,7 +2675,7 @@ components: note: type: string maxLength: 240 - description: 사용자가 Moment 저장 시 남긴 짧은 메모 + description: 사용자가 리캡 저장 시 남긴 짧은 메모 placeCategory: type: string trackId: @@ -2405,11 +2699,11 @@ components: photo: type: string format: binary - description: 새 Moment 사진 파일 + description: 새 리캡 사진 파일 MomentLogUpdateRequest: type: object - description: MomentLog 부분 수정 요청. 전달하지 않은 필드는 기존 값을 유지합니다. + description: 리캡 캡처 부분 수정 요청. 전달하지 않은 필드는 기존 값을 유지합니다. 내부 저장 모델명은 MomentLog입니다. properties: createdAt: type: string @@ -2456,6 +2750,7 @@ components: MomentLog: type: object + description: 카메라로 저장한 리캡 캡처 DTO입니다. 내부 DB 모델명은 MomentLog로 유지됩니다. required: - id - createdAt @@ -2570,6 +2865,7 @@ components: RecapItem: type: object + description: 로그 탭에 표시되는 여행 로그 또는 낱개 리캡 요약입니다. required: - id - title @@ -2596,6 +2892,8 @@ components: example: 8 sessionId: type: string + visibility: + $ref: "#/components/schemas/RecapVisibility" RecapTemplateId: type: string @@ -2603,10 +2901,19 @@ components: - album - film - lp + - map - video + RecapVisibility: + type: string + enum: + - private + - public + description: private는 나만보기, public은 여행모드 지도에서 현재 위치 300m 이내 사용자에게 노출되는 공개 상태입니다. + RecapCreateRequest: type: object + description: 여행 로그 생성 요청입니다. visibility가 public이면 대표 위치를 만들 수 있는 리캡 캡처 위치가 필요합니다. required: - templateId properties: @@ -2622,6 +2929,75 @@ components: type: string representativeTrackId: type: string + routePoints: + type: array + description: 여행모드 동안 클라이언트가 foreground에서 수집한 이동 경로 좌표입니다. 서버 폴링 없이 디바운스된 클라이언트 푸시, 앱 상태 전환, 여행 종료 또는 Recap 생성 시 저장합니다. + maxItems: 500 + items: + $ref: "#/components/schemas/RoutePoint" + visibility: + $ref: "#/components/schemas/RecapVisibility" + + RecapVisibilityUpdateRequest: + type: object + description: public으로 변경하려면 해당 리캡에 대표 위치가 있어야 합니다. + required: + - visibility + properties: + visibility: + $ref: "#/components/schemas/RecapVisibility" + + RecapMapMarker: + type: object + required: + - id + - recapId + - title + - placeName + - ownerAlias + - location + - trackTitle + - artistName + - templateId + - visibility + - createdAt + properties: + id: + type: string + example: marker-recap_001 + recapId: + type: string + example: recap_001 + title: + type: string + example: 광안리의 밤 산책 + placeName: + type: string + example: 광안리해수욕장 + ownerAlias: + type: string + example: Soundlog 여행자 + location: + $ref: "#/components/schemas/GeoPoint" + trackTitle: + type: string + example: Ocean Drive + artistName: + type: string + example: Soundlog Curator + templateId: + $ref: "#/components/schemas/RecapTemplateId" + visibility: + $ref: "#/components/schemas/RecapVisibility" + distanceMeters: + type: integer + example: 142 + imageUrl: + type: string + format: uri + createdAt: + type: string + format: date-time RecapShareMoment: type: object @@ -2637,6 +3013,8 @@ components: imageUrl: type: string format: uri + location: + $ref: "#/components/schemas/GeoPoint" placeName: type: string example: 광안리해수욕장 @@ -2677,12 +3055,19 @@ components: type: array items: $ref: "#/components/schemas/RecapShareMoment" + routePoints: + type: array + description: 리캡 지도에서 선으로 표시할 여행 이동 경로입니다. 정확한 이동 경로 보호를 위해 리캡 소유자에게만 내려갑니다. + items: + $ref: "#/components/schemas/RoutePoint" recordedAt: type: string format: date-time shareImageUrl: type: string format: uri + visibility: + $ref: "#/components/schemas/RecapVisibility" RecapShareEventRequest: type: object @@ -3269,6 +3654,11 @@ components: format: date-time location: $ref: "#/components/schemas/GeoPoint" + routePoints: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/RoutePoint" travelMode: $ref: "#/components/schemas/TravelMode" @@ -3287,6 +3677,11 @@ components: format: date-time location: $ref: "#/components/schemas/GeoPoint" + routePoints: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/RoutePoint" TravelSession: type: object @@ -3309,6 +3704,10 @@ components: endedAt: type: string format: date-time + routePoints: + type: array + items: + $ref: "#/components/schemas/RoutePoint" travelMode: $ref: "#/components/schemas/TravelMode" @@ -3626,6 +4025,16 @@ components: data: $ref: "#/components/schemas/RecapShare" + RecapMapMarkerListResponse: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/RecapMapMarker" + TravelSessionResponse: type: object required: diff --git a/prisma/migrations/20260711000000_recap_map_visibility/migration.sql b/prisma/migrations/20260711000000_recap_map_visibility/migration.sql new file mode 100644 index 0000000..45232a0 --- /dev/null +++ b/prisma/migrations/20260711000000_recap_map_visibility/migration.sql @@ -0,0 +1,17 @@ +ALTER TABLE "Recap" +ADD COLUMN "templateId" TEXT NOT NULL DEFAULT 'album', +ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'private', +ADD COLUMN "lat" DOUBLE PRECISION, +ADD COLUMN "lng" DOUBLE PRECISION; + +UPDATE "Recap" +SET + "lat" = COALESCE( + "lat", + NULLIF(("moments" #>> '{0,location,lat}'), '')::DOUBLE PRECISION + ), + "lng" = COALESCE( + "lng", + NULLIF(("moments" #>> '{0,location,lng}'), '')::DOUBLE PRECISION + ) +WHERE "moments" IS NOT NULL; diff --git a/prisma/migrations/20260712000000_travel_route_points/migration.sql b/prisma/migrations/20260712000000_travel_route_points/migration.sql new file mode 100644 index 0000000..eaaa425 --- /dev/null +++ b/prisma/migrations/20260712000000_travel_route_points/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "TravelSession" +ADD COLUMN "routePoints" JSONB; + +ALTER TABLE "Recap" +ADD COLUMN "routePoints" JSONB; diff --git a/prisma/models/recap.prisma b/prisma/models/recap.prisma index 8df4fe1..e22e179 100644 --- a/prisma/models/recap.prisma +++ b/prisma/models/recap.prisma @@ -12,6 +12,11 @@ model Recap { recordedAt DateTime? shareImageUrl String? moments Json? + routePoints Json? + templateId String @default("album") + visibility String @default("private") + lat Float? + lng Float? user User @relation(fields: [userId], references: [id], onDelete: Cascade) representativeTrack Track @relation("RepresentativeTrack", fields: [representativeTrackId], references: [id]) shareEvents RecapShareEvent[] diff --git a/prisma/models/travel.prisma b/prisma/models/travel.prisma index 0426264..6ca8419 100644 --- a/prisma/models/travel.prisma +++ b/prisma/models/travel.prisma @@ -43,5 +43,6 @@ model TravelSession { travelMode String? lat Float? lng Float? + routePoints Json? user User @relation(fields: [userId], references: [id], onDelete: Cascade) } diff --git a/prisma/seed.ts b/prisma/seed.ts index ba1604f..0ce5d50 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -241,8 +241,12 @@ export async function seedDatabase() { sessionId: recap.sessionId, backgroundImageUrl: recap.backgroundImageUrl, discImageUrl: recap.discImageUrl, + lat: recap.lat, + lng: recap.lng, recordedAt: new Date(recap.recordedAt), moments: recap.moments, + templateId: recap.templateId, + visibility: recap.visibility, }, }); } diff --git a/src/constants/error.constants.ts b/src/constants/error.constants.ts index 4b796f7..d92ddea 100644 --- a/src/constants/error.constants.ts +++ b/src/constants/error.constants.ts @@ -24,6 +24,7 @@ export const ERROR_MESSAGES = { MOMENT_LOG_NOT_FOUND: 'Moment Log를 찾을 수 없습니다.', USER_ALREADY_EXISTS: '이미 가입된 이메일입니다.', RECAP_NOT_FOUND: '리캡을 찾을 수 없습니다.', + RECAP_PUBLIC_LOCATION_REQUIRED: '전체공개 리캡은 대표 위치가 필요합니다.', RECOMMENDATION_TRACK_NOT_FOUND: '추천 트랙을 찾을 수 없습니다.', REGION_SOUND_TREND_NOT_FOUND: '지역 사운드 트렌드를 찾을 수 없습니다.', REPRESENTATIVE_TRACK_NOT_FOUND: '대표 트랙을 찾을 수 없습니다.', diff --git a/src/controllers/recap.controller.ts b/src/controllers/recap.controller.ts index c9bb9f9..163d09c 100644 --- a/src/controllers/recap.controller.ts +++ b/src/controllers/recap.controller.ts @@ -5,6 +5,11 @@ import { apiService } from '../services/api.service.js'; import { acceptedResponse, dataResponse } from '../utils/response.js'; export const recapController = { + async getRecapMarkers(req: Request, res: Response) { + const user = requireUser(req); + res.json(dataResponse(await apiService.getRecapMarkers(user.id, req.query))); + }, + async getRecaps(req: Request, res: Response) { const user = requireUser(req); res.json(await apiService.getRecaps(user.id, req.query)); @@ -28,6 +33,19 @@ export const recapController = { res.json(dataResponse(await apiService.getRecapShare(user.id, String(req.params.recapId)))); }, + async updateRecapVisibility(req: Request, res: Response) { + const user = requireUser(req); + res.json( + dataResponse( + await apiService.updateRecapVisibility( + user.id, + String(req.params.recapId), + req.body, + ), + ), + ); + }, + async createShareEvent(req: Request, res: Response) { const user = requireUser(req); await apiService.createRecapShareEvent( diff --git a/src/data/seed-data.ts b/src/data/seed-data.ts index eb4b2ca..40773a3 100644 --- a/src/data/seed-data.ts +++ b/src/data/seed-data.ts @@ -256,7 +256,11 @@ export const recaps = [ sessionId: 'seed-session', backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', discImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', + lat: 35.1532, + lng: 129.1187, recordedAt: '2024-04-24T18:20:00.000+09:00', + templateId: 'lp', + visibility: 'public', moments: [ { id: 'log-1', @@ -279,7 +283,11 @@ export const recaps = [ sessionId: 'seed-session', backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', discImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', + lat: 35.1532, + lng: 129.1187, recordedAt: '2026-05-25T00:00:00.000Z', + templateId: 'album', + visibility: 'private', moments: [ { id: 'log-1', diff --git a/src/mock/mock-db.ts b/src/mock/mock-db.ts index c1b84dd..2271dba 100644 --- a/src/mock/mock-db.ts +++ b/src/mock/mock-db.ts @@ -52,14 +52,20 @@ type MockRecap = { createdAt: Date; discImageUrl?: string; id: string; + lat?: number; + lng?: number; momentCount?: number; moments?: unknown[]; placeName: string; recordedAt?: Date; representativeTrackId: string; + routePoints?: MockRoutePoint[]; sessionId?: string; shareImageUrl?: string; + templateId: string; title: string; + userId: string; + visibility: 'private' | 'public'; }; type MockTravelSession = { @@ -67,12 +73,20 @@ type MockTravelSession = { id: string; lat?: number; lng?: number; + routePoints?: MockRoutePoint[]; startedAt?: Date; status: 'active' | 'ended' | 'idle'; travelMode?: string; userId: string; }; +type MockRoutePoint = { + accuracyMeters?: number; + lat: number; + lng: number; + recordedAt: string; +}; + type MockRefreshToken = { expiresAt: Date; tokenHash: string; @@ -250,19 +264,31 @@ function createMockDb() { userId: string; value?: string; }>, - recaps: recaps.map((recap) => ({ - id: recap.id, - title: recap.title, - placeName: recap.placeName, - representativeTrackId: recap.representativeTrackId, - createdAt: new Date(recap.createdAt), - momentCount: recap.momentCount, - sessionId: recap.sessionId, - backgroundImageUrl: recap.backgroundImageUrl, - discImageUrl: recap.discImageUrl, - recordedAt: new Date(recap.recordedAt), - moments: [...recap.moments], - })) as MockRecap[], + recaps: recaps.map((recap) => { + const routePoints = 'routePoints' in recap + ? (recap.routePoints as MockRoutePoint[] | undefined) + : undefined; + + return { + id: recap.id, + userId: 'mock-user-local', + title: recap.title, + placeName: recap.placeName, + representativeTrackId: recap.representativeTrackId, + createdAt: new Date(recap.createdAt), + momentCount: recap.momentCount, + sessionId: recap.sessionId, + backgroundImageUrl: recap.backgroundImageUrl, + discImageUrl: recap.discImageUrl, + recordedAt: new Date(recap.recordedAt), + moments: [...recap.moments], + routePoints: routePoints ? [...routePoints] : undefined, + templateId: recap.templateId ?? 'album', + visibility: recap.visibility ?? 'private', + lat: recap.lat, + lng: recap.lng, + }; + }) as MockRecap[], recapShareEvents: [] as Array<{ createdAt: Date; id: string; diff --git a/src/routes/index.ts b/src/routes/index.ts index aba249e..454a726 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -146,6 +146,48 @@ export function createApiRouter() { asyncHandler(libraryController.updateTrackState), ); + router.get( + '/v1/recap-captures', + authMiddleware, + validate({ query: momentLogValidators.listQuery }), + asyncHandler(momentLogController.getMomentLogs), + ); + router.post( + '/v1/recap-captures', + authMiddleware, + momentPhotoUpload.single('photo'), + validate({ body: momentLogValidators.createBody }), + asyncHandler(momentLogController.createMomentLog), + ); + router.patch( + '/v1/recap-captures/:momentLogId', + authMiddleware, + validate({ + params: momentLogValidators.momentLogParams, + body: momentLogValidators.updateBody, + }), + asyncHandler(momentLogController.updateMomentLog), + ); + router.put( + '/v1/recap-captures/:momentLogId/photo', + authMiddleware, + momentPhotoUpload.single('photo'), + validate({ params: momentLogValidators.momentLogParams }), + asyncHandler(momentLogController.updateMomentLogPhoto), + ); + router.delete( + '/v1/recap-captures/:momentLogId/photo', + authMiddleware, + validate({ params: momentLogValidators.momentLogParams }), + asyncHandler(momentLogController.deleteMomentLogPhoto), + ); + router.delete( + '/v1/recap-captures/:momentLogId', + authMiddleware, + validate({ params: momentLogValidators.momentLogParams }), + asyncHandler(momentLogController.deleteMomentLog), + ); + router.get( '/v1/moment-logs', authMiddleware, @@ -195,6 +237,12 @@ export function createApiRouter() { asyncHandler(recommendationEventController.createEvents), ); + router.get( + '/v1/recap-markers', + authMiddleware, + validate({ query: recapValidators.markerQuery }), + asyncHandler(recapController.getRecapMarkers), + ); router.get( '/v1/recaps', authMiddleware, @@ -213,6 +261,15 @@ export function createApiRouter() { validate({ params: recapValidators.recapParams }), asyncHandler(recapController.getRecapShare), ); + router.patch( + '/v1/recaps/:recapId/visibility', + authMiddleware, + validate({ + params: recapValidators.recapParams, + body: recapValidators.visibilityBody, + }), + asyncHandler(recapController.updateRecapVisibility), + ); router.post( '/v1/recaps/:recapId/share-events', authMiddleware, diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index 643071d..9610853 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -33,6 +33,17 @@ type MomentLogUpdateInput = { travelMode?: string | null; }; +type RecapListScope = 'all' | 'mine' | 'others'; +type RecapMapScope = 'mine' | 'public'; +type RecapVisibility = 'private' | 'public'; +type RoutePointDto = { + accuracyMeters?: number; + lat: number; + lng: number; + recordedAt: string; +}; + +const RECAP_DISCOVERY_RADIUS_METERS = 300; const TRAVEL_MATE_REQUEST_COOLDOWN_MS = 24 * 60 * 60 * 1000; const CLOSED_TRAVEL_MATE_REQUEST_STATUSES = ['cancelled', 'declined', 'expired']; @@ -42,6 +53,38 @@ function compact>(value: T) { ) as Partial; } +function normalizeRoutePoints(routePoints?: RoutePointDto[]) { + if (!routePoints) { + return undefined; + } + + return routePoints.map((point) => + compact({ + accuracyMeters: point.accuracyMeters, + lat: point.lat, + lng: point.lng, + recordedAt: point.recordedAt, + }) as RoutePointDto, + ); +} + +function createInitialRoutePoints( + location: { lat: number; lng: number } | undefined, + recordedAt: Date, +) { + if (!location) { + return undefined; + } + + return normalizeRoutePoints([ + { + lat: location.lat, + lng: location.lng, + recordedAt: recordedAt.toISOString(), + }, + ]); +} + function hasOwn( value: T, key: K, @@ -216,16 +259,19 @@ function recapItemToDto(recap: (typeof mockDb.recaps)[number]) { createdAt: recap.createdAt.toISOString(), momentCount: recap.momentCount, sessionId: recap.sessionId, + visibility: recap.visibility, }); } -function recapShareToDto(recap: (typeof mockDb.recaps)[number]) { +function recapShareToDto(recap: (typeof mockDb.recaps)[number], viewerId?: string) { const track = findMockTrack(recap.representativeTrackId); if (!track) { throw notFound(ERROR_MESSAGES.REPRESENTATIVE_TRACK_NOT_FOUND); } + const canViewRoutePoints = recap.userId === viewerId; + return compact({ id: recap.id, placeName: recap.placeName, @@ -235,7 +281,80 @@ function recapShareToDto(recap: (typeof mockDb.recaps)[number]) { discImageUrl: recap.discImageUrl, moments: recap.moments, recordedAt: (recap.recordedAt ?? recap.createdAt).toISOString(), + routePoints: canViewRoutePoints ? recap.routePoints : undefined, shareImageUrl: recap.shareImageUrl, + visibility: recap.visibility, + }); +} + +function getMockRecapMomentLocation(recap: (typeof mockDb.recaps)[number]) { + const moments = Array.isArray(recap.moments) + ? (recap.moments as Array<{ location?: { lat?: unknown; lng?: unknown } }>) + : []; + const location = moments.find( + (moment) => + typeof moment.location?.lat === 'number' && + typeof moment.location?.lng === 'number', + )?.location; + + if (!location || typeof location.lat !== 'number' || typeof location.lng !== 'number') { + return undefined; + } + + return { + lat: location.lat, + lng: location.lng, + }; +} + +function getMockRecapLocation(recap: (typeof mockDb.recaps)[number]) { + if (recap.lat !== undefined && recap.lng !== undefined) { + return { + lat: recap.lat, + lng: recap.lng, + }; + } + + return getMockRecapMomentLocation(recap); +} + +function assertMockPublicRecapHasLocation( + visibility: RecapVisibility | undefined, + location: { lat: number; lng: number } | undefined, +) { + if (visibility === 'public' && !location) { + throw badRequest(ERROR_MESSAGES.RECAP_PUBLIC_LOCATION_REQUIRED); + } +} + +function mockRecapMapMarkerToDto( + recap: (typeof mockDb.recaps)[number], + viewerId: string, + origin?: { lat: number; lng: number }, +) { + const location = getMockRecapLocation(recap); + const track = findMockTrack(recap.representativeTrackId); + + if (!location || !track) { + return undefined; + } + + const isMine = recap.userId === viewerId; + + return compact({ + id: `marker-${recap.id}`, + recapId: recap.id, + title: recap.title, + placeName: recap.placeName, + ownerAlias: isMine ? '나' : 'Soundlog 여행자', + location, + trackTitle: track.title, + artistName: track.artist, + templateId: recap.templateId, + visibility: recap.visibility, + distanceMeters: origin ? Math.round(distanceMeters(origin, location)) : undefined, + imageUrl: recap.backgroundImageUrl, + createdAt: recap.createdAt.toISOString(), }); } @@ -1369,6 +1488,7 @@ export const mockSoundlogService = { const recap = { id: createPublicId('recap'), + userId, title: input.title ?? `${room.title} 공동 Recap`, placeName: recapMoments[0]?.placeName ?? room.title, representativeTrackId, @@ -1376,6 +1496,8 @@ export const mockSoundlogService = { momentCount: recapMoments.length, sessionId: room.sessionId, recordedAt: recapMoments[0]?.createdAt ?? new Date(), + templateId: input.templateId ?? 'album', + visibility: 'private' as const, moments: recapMoments.map((moment) => ({ id: moment.id, placeName: moment.placeName ?? '위치 없음', @@ -1774,10 +1896,23 @@ export const mockSoundlogService = { }, { sessionId: input.requestId ?? input.targetPinId ?? input.targetUserId ?? 'community' }); }, - async getRecaps(_userId: string, params: { cursor?: string; limit?: number }) { - const recaps = [...mockDb.recaps].sort( - (first, second) => second.createdAt.getTime() - first.createdAt.getTime(), - ); + async getRecaps(userId: string, params: { + cursor?: string; + limit?: number; + scope?: RecapListScope; + }) { + const scope = params.scope ?? 'mine'; + const recaps = mockDb.recaps + .filter((recap) => + scope === 'mine' + ? recap.userId === userId + : scope === 'others' + ? recap.userId !== userId && recap.visibility === 'public' + : recap.userId === userId || recap.visibility === 'public', + ) + .sort( + (first, second) => second.createdAt.getTime() - first.createdAt.getTime(), + ); const limit = getLimit(params.limit); const page = paginateByCursor(recaps, limit, params.cursor); @@ -1790,11 +1925,43 @@ export const mockSoundlogService = { }; }, + async getRecapMarkers(userId: string, params: { + lat?: number; + lng?: number; + radiusMeters?: number; + scope?: RecapMapScope; + }) { + const scope = params.scope ?? 'public'; + const origin = + params.lat !== undefined && params.lng !== undefined + ? { lat: params.lat, lng: params.lng } + : undefined; + const radiusMeters = RECAP_DISCOVERY_RADIUS_METERS; + + return mockDb.recaps + .filter((recap) => + scope === 'mine' + ? recap.userId === userId + : recap.visibility === 'public', + ) + .flatMap((recap) => { + const marker = mockRecapMapMarkerToDto(recap, userId, origin); + + return marker ? [marker] : []; + }) + .filter((marker) => + origin ? typeof marker.distanceMeters === 'number' && marker.distanceMeters <= radiusMeters : true, + ); + }, + async createRecap(userId: string, input: { momentLogIds?: string[]; representativeTrackId?: string; + routePoints?: RoutePointDto[]; sessionId?: string; + templateId?: string; title?: string; + visibility?: RecapVisibility; }, idempotencyKey?: string) { return withMockIdempotency( { idempotencyKey, scope: 'recap.create', userId }, @@ -1826,8 +1993,30 @@ export const mockSoundlogService = { throw notFound(ERROR_MESSAGES.REPRESENTATIVE_TRACK_NOT_FOUND); } + const firstMomentLocation = + firstMoment?.lat !== undefined && firstMoment?.lng !== undefined + ? { lat: firstMoment.lat, lng: firstMoment.lng } + : undefined; + const travelSession = input.sessionId + ? mockDb.travelSessions.find( + (session) => session.id === input.sessionId && session.userId === userId, + ) + : undefined; + const routePoints = + normalizeRoutePoints(input.routePoints) ?? + normalizeRoutePoints(travelSession?.routePoints); + const firstRoutePoint = input.routePoints?.[0] ?? travelSession?.routePoints?.[0]; + const recapLocation = firstMomentLocation ?? ( + firstRoutePoint + ? { lat: firstRoutePoint.lat, lng: firstRoutePoint.lng } + : undefined + ); + + assertMockPublicRecapHasLocation(input.visibility, recapLocation); + const recap = { id: createPublicId('recap'), + userId, title: input.title ?? `${firstMoment?.placeName ?? '여행'}의 사운드`, placeName: firstMoment?.placeName ?? 'Soundlog', representativeTrackId, @@ -1836,7 +2025,12 @@ export const mockSoundlogService = { sessionId: input.sessionId, backgroundImageUrl: firstMoment?.photoUrl, discImageUrl: firstMoment?.photoUrl, + lat: recapLocation?.lat, + lng: recapLocation?.lng, recordedAt: firstMoment?.createdAt ?? new Date(), + routePoints, + templateId: input.templateId ?? 'album', + visibility: input.visibility ?? 'private', moments: moments.map((moment) => ({ id: moment.id, imageUrl: moment.photoUrl, @@ -1858,21 +2052,41 @@ export const mockSoundlogService = { ); }, - async getRecapShare(_userId: string, recapId: string) { - const recap = mockDb.recaps.find((item) => item.id === recapId); + async getRecapShare(userId: string, recapId: string) { + const recap = mockDb.recaps.find( + (item) => item.id === recapId && (item.userId === userId || item.visibility === 'public'), + ); if (!recap) { throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); } - return recapShareToDto(recap); + return recapShareToDto(recap, userId); + }, + + async updateRecapVisibility( + userId: string, + recapId: string, + input: { visibility: RecapVisibility }, + ) { + const recap = mockDb.recaps.find((item) => item.id === recapId && item.userId === userId); + + if (!recap) { + throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); + } + + assertMockPublicRecapHasLocation(input.visibility, getMockRecapLocation(recap)); + + recap.visibility = input.visibility; + + return recapItemToDto(recap); }, - async createRecapShareEvent(_userId: string, recapId: string, input: { + async createRecapShareEvent(userId: string, recapId: string, input: { createdAt: string; type: string; }, _idempotencyKey?: string) { - if (!mockDb.recaps.some((recap) => recap.id === recapId)) { + if (!mockDb.recaps.some((recap) => recap.id === recapId && recap.userId === userId)) { throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); } @@ -1886,16 +2100,22 @@ export const mockSoundlogService = { async createTravelSession(userId: string, input: { location?: { lat: number; lng: number }; + routePoints?: RoutePointDto[]; startedAt?: string; travelMode?: string; }) { + const startedAt = input.startedAt ? new Date(input.startedAt) : new Date(); + const routePoints = + normalizeRoutePoints(input.routePoints) ?? + createInitialRoutePoints(input.location, startedAt); const session = { id: createPublicId('session'), status: 'active' as const, - startedAt: input.startedAt ? new Date(input.startedAt) : new Date(), + startedAt, travelMode: input.travelMode, lat: input.location?.lat, lng: input.location?.lng, + routePoints, userId, }; @@ -1905,6 +2125,7 @@ export const mockSoundlogService = { id: session.id, status: session.status, startedAt: session.startedAt.toISOString(), + routePoints: session.routePoints, travelMode: session.travelMode, }; }, @@ -1912,6 +2133,7 @@ export const mockSoundlogService = { async updateTravelSession(userId: string, sessionId: string, input: { endedAt?: string; location?: { lat: number; lng: number }; + routePoints?: RoutePointDto[]; status: 'active' | 'ended'; }) { const session = mockDb.travelSessions.find( @@ -1935,6 +2157,7 @@ export const mockSoundlogService = { : undefined; session.lat = input.location?.lat ?? session.lat; session.lng = input.location?.lng ?? session.lng; + session.routePoints = normalizeRoutePoints(input.routePoints) ?? session.routePoints; if (session.status === 'ended') { const now = new Date(); @@ -1952,6 +2175,7 @@ export const mockSoundlogService = { status: session.status, startedAt: session.startedAt?.toISOString(), endedAt: session.endedAt?.toISOString(), + routePoints: session.routePoints, travelMode: session.travelMode, }); }, diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index 2df69ef..84a9f85 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -45,6 +45,15 @@ type TrackDto = { type RecommendationContext = Record; type CommunityVisibility = 'companions' | 'nearby' | 'private'; +type RecapListScope = 'all' | 'mine' | 'others'; +type RecapMapScope = 'mine' | 'public'; +type RecapVisibility = 'private' | 'public'; +type RoutePointDto = { + accuracyMeters?: number; + lat: number; + lng: number; + recordedAt: string; +}; type RoomWithCommunity = TravelRoom & { members: TravelRoomMember[]; moments: Array; @@ -76,6 +85,8 @@ type TourApiResponse = { type MlTravelState = '바다' | '드라이브' | '산책' | '카페' | '야경'; type MlMood = '잔잔한' | '신나는' | '시원한' | '설레는' | '감성적인'; +const RECAP_DISCOVERY_RADIUS_METERS = 300; + type MlRecommendationResponse = { tracks?: unknown; }; @@ -111,6 +122,14 @@ type MomentLogUpdateInput = { travelMode?: string | null; }; +type RecapWithMarkerRelations = Recap & { + representativeTrack: Track; + user: { + displayName: string | null; + id: string; + }; +}; + function compact>(value: T) { return Object.fromEntries( Object.entries(value).filter(([, item]) => item !== undefined && item !== null), @@ -128,6 +147,83 @@ function toInputJson(value: unknown): Prisma.InputJsonValue { return JSON.parse(JSON.stringify(value ?? { accepted: true })) as Prisma.InputJsonValue; } +function isRoutePoint(value: unknown): value is RoutePointDto { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as { + accuracyMeters?: unknown; + lat?: unknown; + lng?: unknown; + recordedAt?: unknown; + }; + + return ( + typeof candidate.lat === 'number' && + typeof candidate.lng === 'number' && + typeof candidate.recordedAt === 'string' && + ( + candidate.accuracyMeters === undefined || + typeof candidate.accuracyMeters === 'number' + ) + ); +} + +function routePointsToDto(value?: Prisma.JsonValue | null) { + if (!Array.isArray(value)) { + return undefined; + } + + const routePoints = value.filter(isRoutePoint).map((point): RoutePointDto => { + const routePoint: RoutePointDto = { + lat: point.lat, + lng: point.lng, + recordedAt: point.recordedAt, + }; + + if (point.accuracyMeters !== undefined) { + routePoint.accuracyMeters = point.accuracyMeters; + } + + return routePoint; + }); + + return routePoints.length ? routePoints : undefined; +} + +function normalizeRoutePoints(routePoints?: RoutePointDto[]) { + if (!routePoints) { + return undefined; + } + + return routePoints.map((point) => + compact({ + accuracyMeters: point.accuracyMeters, + lat: point.lat, + lng: point.lng, + recordedAt: point.recordedAt, + }), + ) as Prisma.InputJsonArray; +} + +function createInitialRoutePoints( + location: { lat: number; lng: number } | undefined, + recordedAt: Date, +) { + if (!location) { + return undefined; + } + + return normalizeRoutePoints([ + { + lat: location.lat, + lng: location.lng, + recordedAt: recordedAt.toISOString(), + }, + ]); +} + function normalizeApproxCoordinate(value: number) { return Math.round(value * 100) / 100; } @@ -1040,10 +1136,13 @@ function recapItemToDto(recap: Recap & { representativeTrack: Track }) { createdAt: recap.createdAt.toISOString(), momentCount: recap.momentCount ?? undefined, sessionId: recap.sessionId ?? undefined, + visibility: recap.visibility, }); } -function recapShareToDto(recap: Recap & { representativeTrack: Track }) { +function recapShareToDto(recap: Recap & { representativeTrack: Track }, viewerId?: string) { + const canViewRoutePoints = recap.userId === viewerId; + return compact({ id: recap.id, placeName: recap.placeName, @@ -1053,7 +1152,79 @@ function recapShareToDto(recap: Recap & { representativeTrack: Track }) { discImageUrl: recap.discImageUrl ?? undefined, moments: (recap.moments as Prisma.JsonArray | null) ?? undefined, recordedAt: (recap.recordedAt ?? recap.createdAt).toISOString(), + routePoints: canViewRoutePoints ? routePointsToDto(recap.routePoints) : undefined, shareImageUrl: recap.shareImageUrl ?? undefined, + visibility: recap.visibility, + }); +} + +function getRecapMomentLocation(recap: Recap) { + const moments = Array.isArray(recap.moments) + ? (recap.moments as Array<{ location?: { lat?: unknown; lng?: unknown } }>) + : []; + const location = moments.find( + (moment) => + typeof moment.location?.lat === 'number' && + typeof moment.location?.lng === 'number', + )?.location; + + if (!location || typeof location.lat !== 'number' || typeof location.lng !== 'number') { + return undefined; + } + + return { + lat: location.lat, + lng: location.lng, + }; +} + +function getRecapLocation(recap: Recap) { + if (recap.lat !== null && recap.lng !== null) { + return { + lat: recap.lat, + lng: recap.lng, + }; + } + + return getRecapMomentLocation(recap); +} + +function assertPublicRecapHasLocation( + visibility: RecapVisibility | undefined, + location: { lat: number; lng: number } | undefined, +) { + if (visibility === 'public' && !location) { + throw badRequest(ERROR_MESSAGES.RECAP_PUBLIC_LOCATION_REQUIRED); + } +} + +function recapMapMarkerToDto( + recap: RecapWithMarkerRelations, + viewerId: string, + origin?: { lat: number; lng: number }, +) { + const location = getRecapLocation(recap); + + if (!location) { + return undefined; + } + + const isMine = recap.userId === viewerId; + + return compact({ + id: `marker-${recap.id}`, + recapId: recap.id, + title: recap.title, + placeName: recap.placeName, + ownerAlias: isMine ? '나' : recap.user.displayName ?? 'Soundlog 여행자', + location, + trackTitle: recap.representativeTrack.title, + artistName: recap.representativeTrack.artist, + templateId: recap.templateId, + visibility: recap.visibility as RecapVisibility, + distanceMeters: origin ? Math.round(distanceMeters(origin, location)) : undefined, + imageUrl: recap.backgroundImageUrl ?? undefined, + createdAt: recap.createdAt.toISOString(), }); } @@ -1080,6 +1251,7 @@ function travelSessionToDto(session: TravelSession) { status: session.status, startedAt: session.startedAt?.toISOString(), endedAt: session.endedAt?.toISOString(), + routePoints: routePointsToDto(session.routePoints), travelMode: session.travelMode ?? undefined, }); } @@ -2211,6 +2383,8 @@ export const soundlogService = { momentCount: recapMoments.length, sessionId: room.sessionId, recordedAt: recapMoments[0]?.createdAt ?? new Date(), + templateId: input.templateId ?? 'album', + visibility: 'private', moments: recapMoments.map((moment) => { const momentTrack = (moment.trackSnapshot as TrackDto | null) ?? undefined; return { @@ -2715,9 +2889,25 @@ export const soundlogService = { }, { sessionId: input.requestId ?? input.targetPinId ?? input.targetUserId ?? 'community' }); }, - async getRecaps(userId: string, params: { cursor?: string; limit?: number }) { + async getRecaps(userId: string, params: { + cursor?: string; + limit?: number; + scope?: RecapListScope; + }) { + const scope = params.scope ?? 'mine'; + const where: Prisma.RecapWhereInput = + scope === 'mine' + ? { userId } + : scope === 'others' + ? { userId: { not: userId }, visibility: 'public' } + : { + OR: [ + { userId }, + { userId: { not: userId }, visibility: 'public' }, + ], + }; const recaps = await prisma.recap.findMany({ - where: { userId }, + where, include: { representativeTrack: true }, orderBy: { createdAt: 'desc' }, }); @@ -2733,14 +2923,55 @@ export const soundlogService = { }; }, + async getRecapMarkers( + userId: string, + params: { + lat?: number; + lng?: number; + radiusMeters?: number; + scope?: RecapMapScope; + }, + ) { + const scope = params.scope ?? 'public'; + const origin = hasGeoPoint(params) ? { lat: params.lat!, lng: params.lng! } : undefined; + const radiusMeters = RECAP_DISCOVERY_RADIUS_METERS; + const recaps = await prisma.recap.findMany({ + where: scope === 'mine' + ? { userId } + : { visibility: 'public' }, + include: { + representativeTrack: true, + user: { + select: { + displayName: true, + id: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return recaps + .flatMap((recap) => { + const marker = recapMapMarkerToDto(recap, userId, origin); + + return marker ? [marker] : []; + }) + .filter((marker) => + origin ? typeof marker.distanceMeters === 'number' && marker.distanceMeters <= radiusMeters : true, + ); + }, + async createRecap( userId: string, input: { momentLogIds?: string[]; representativeTrackId?: string; + routePoints?: RoutePointDto[]; sessionId?: string; templateId: string; title?: string; + visibility?: RecapVisibility; }, idempotencyKey?: string, ) { @@ -2755,6 +2986,18 @@ export const soundlogService = { }, orderBy: { createdAt: 'asc' }, }); + const travelSession = input.sessionId + ? await prisma.travelSession.findFirst({ + where: { + id: input.sessionId, + userId, + }, + }) + : undefined; + const sessionRoutePoints = routePointsToDto(travelSession?.routePoints); + const routePoints = + normalizeRoutePoints(input.routePoints) ?? + normalizeRoutePoints(sessionRoutePoints); const candidateTrackIds = input.representativeTrackId ? [input.representativeTrackId] : Array.from( @@ -2785,6 +3028,25 @@ export const soundlogService = { } const firstMoment = moments[0]; + const firstMomentLocation = + firstMoment?.lat !== null && + firstMoment?.lat !== undefined && + firstMoment?.lng !== null && + firstMoment?.lng !== undefined + ? { + lat: firstMoment.lat, + lng: firstMoment.lng, + } + : undefined; + const firstRoutePoint = input.routePoints?.[0] ?? sessionRoutePoints?.[0]; + const recapLocation = firstMomentLocation ?? ( + firstRoutePoint + ? { lat: firstRoutePoint.lat, lng: firstRoutePoint.lng } + : undefined + ); + + assertPublicRecapHasLocation(input.visibility, recapLocation); + const recap = await prisma.recap.create({ data: { id: createPublicId('recap'), @@ -2798,6 +3060,11 @@ export const soundlogService = { discImageUrl: firstMoment?.photoUrl, recordedAt: firstMoment?.createdAt ?? new Date(), moments: moments.map(momentLogToRecapShareMoment) as Prisma.JsonArray, + routePoints, + templateId: input.templateId, + visibility: input.visibility ?? 'private', + lat: recapLocation?.lat, + lng: recapLocation?.lng, }, include: { representativeTrack: true }, }); @@ -2811,7 +3078,10 @@ export const soundlogService = { const recap = await prisma.recap.findFirst({ where: { id: recapId, - userId, + OR: [ + { userId }, + { visibility: 'public' }, + ], }, include: { representativeTrack: true }, }); @@ -2820,7 +3090,34 @@ export const soundlogService = { throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); } - return recapShareToDto(recap); + return recapShareToDto(recap, userId); + }, + + async updateRecapVisibility( + userId: string, + recapId: string, + input: { visibility: RecapVisibility }, + ) { + const recap = await prisma.recap.findFirst({ + where: { + id: recapId, + userId, + }, + }); + + if (!recap) { + throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); + } + + assertPublicRecapHasLocation(input.visibility, getRecapLocation(recap)); + + const updatedRecap = await prisma.recap.update({ + where: { id: recapId }, + data: { visibility: input.visibility }, + include: { representativeTrack: true }, + }); + + return recapItemToDto(updatedRecap); }, async createRecapShareEvent( @@ -2861,19 +3158,26 @@ export const soundlogService = { userId: string, input: { location?: { lat: number; lng: number }; + routePoints?: RoutePointDto[]; startedAt?: string; travelMode?: string; }, ) { + const startedAt = input.startedAt ? new Date(input.startedAt) : new Date(); + const routePoints = + normalizeRoutePoints(input.routePoints) ?? + createInitialRoutePoints(input.location, startedAt); + const session = await prisma.travelSession.create({ data: { id: createPublicId('session'), userId, status: 'active', - startedAt: input.startedAt ? new Date(input.startedAt) : new Date(), + startedAt, travelMode: input.travelMode, lat: input.location?.lat, lng: input.location?.lng, + routePoints, }, }); @@ -2886,6 +3190,7 @@ export const soundlogService = { input: { endedAt?: string; location?: { lat: number; lng: number }; + routePoints?: RoutePointDto[]; status: 'active' | 'ended'; }, ) { @@ -2904,6 +3209,8 @@ export const soundlogService = { throw badRequest(ERROR_MESSAGES.ENDED_TRAVEL_SESSION_CANNOT_ACTIVATE); } + const routePoints = normalizeRoutePoints(input.routePoints); + const updated = await prisma.travelSession.update({ where: { id: sessionId }, data: { @@ -2916,6 +3223,7 @@ export const soundlogService = { : undefined, lat: input.location?.lat, lng: input.location?.lng, + routePoints, }, }); diff --git a/src/validators/api.validators.ts b/src/validators/api.validators.ts index 3ed6d06..a611b1c 100644 --- a/src/validators/api.validators.ts +++ b/src/validators/api.validators.ts @@ -86,6 +86,13 @@ const geoPointSchema = z.object({ lng: z.number().min(-180).max(180), }); +const routePointSchema = geoPointSchema.extend({ + accuracyMeters: z.number().min(0).max(10000).optional(), + recordedAt: z.string().datetime(), +}); + +const routePointsSchema = z.array(routePointSchema).max(500); + const optionalLatQuery = z.coerce.number().min(-90).max(90).optional(); const optionalLngQuery = z.coerce.number().min(-180).max(180).optional(); @@ -327,20 +334,32 @@ export const recommendationEventValidators = { }; export const recapValidators = { + markerQuery: z.object({ + lat: optionalLatQuery, + lng: optionalLngQuery, + radiusMeters: z.coerce.number().int().min(50).max(5000).optional().default(300), + scope: z.enum(['public', 'mine']).optional().default('public'), + }), listQuery: z.object({ cursor, limit, + scope: z.enum(['all', 'mine', 'others']).optional().default('mine'), }), createBody: z.object({ momentLogIds: z.array(z.string()).optional(), representativeTrackId: z.string().optional(), + routePoints: routePointsSchema.optional(), sessionId: z.string().optional(), - templateId: z.enum(['album', 'film', 'lp', 'video']), + templateId: z.enum(['album', 'film', 'lp', 'map', 'video']), title: z.string().optional(), + visibility: z.enum(['private', 'public']).optional().default('private'), }), recapParams: z.object({ recapId: z.string().min(1), }), + visibilityBody: z.object({ + visibility: z.enum(['private', 'public']), + }), shareEventBody: z.object({ createdAt: z.string().datetime(), type: z.enum(['save_image', 'os_share', 'instagram', 'snapchat', 'messages']), @@ -351,6 +370,7 @@ export const travelSessionValidators = { createBody: z .object({ location: geoPointSchema.optional(), + routePoints: routePointsSchema.optional(), startedAt: z.string().datetime().optional(), travelMode: travelModeSchema.optional(), }) @@ -362,6 +382,7 @@ export const travelSessionValidators = { updateBody: z.object({ endedAt: z.string().datetime().optional(), location: geoPointSchema.optional(), + routePoints: routePointsSchema.optional(), status: z.enum(['active', 'ended']), }), }; diff --git a/tests/api.test.ts b/tests/api.test.ts index 75c5fc3..dc91c47 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -511,6 +511,35 @@ describe('Soundlog API', () => { expect(minimalMoment.body.data.moodTags).toEqual([]); expect(minimalMoment.body.data.syncStatus).toBe('synced'); + const aliasCreated = await request(app) + .post('/v1/recap-captures') + .set('Authorization', authHeader) + .set('Idempotency-Key', `recap-capture-${Date.now()}`) + .field('createdAt', new Date().toISOString()) + .field('moodTags', 'fresh') + .field('note', '새 리캡 캡처 경로 테스트') + .field('placeName', '리캡 캡처 테스트 장소') + .field('trackId', 'seoul-city') + .attach('photo', Buffer.from('alias-image'), { + filename: 'recap-capture.jpg', + contentType: 'image/jpeg', + }); + expect(aliasCreated.status).toBe(201); + expect(aliasCreated.body.data.note).toBe('새 리캡 캡처 경로 테스트'); + + const aliasUpdated = await request(app) + .patch(`/v1/recap-captures/${aliasCreated.body.data.id}`) + .set('Authorization', authHeader) + .send({ note: '새 리캡 캡처 경로 수정' }); + expect(aliasUpdated.status).toBe(200); + expect(aliasUpdated.body.data.note).toBe('새 리캡 캡처 경로 수정'); + + const aliasList = await request(app) + .get('/v1/recap-captures') + .set('Authorization', authHeader); + expect(aliasList.status).toBe(200); + expect(aliasList.body.data.some((item: { id: string }) => item.id === aliasCreated.body.data.id)).toBe(true); + const updated = await request(app) .patch(`/v1/moment-logs/${created.body.data.id}`) .set('Authorization', authHeader) @@ -559,6 +588,22 @@ describe('Soundlog API', () => { expect(photoDeleted.status).toBe(200); expect(photoDeleted.body.data.photoUrl).toBeUndefined(); + const aliasPhotoUpdated = await request(app) + .put(`/v1/recap-captures/${aliasCreated.body.data.id}/photo`) + .set('Authorization', authHeader) + .attach('photo', Buffer.from('alias-replacement-image'), { + filename: 'recap-capture-replacement.jpg', + contentType: 'image/jpeg', + }); + expect(aliasPhotoUpdated.status).toBe(200); + expect(aliasPhotoUpdated.body.data.photoUrl).toContain('/uploads/'); + + const aliasPhotoDeleted = await request(app) + .delete(`/v1/recap-captures/${aliasCreated.body.data.id}/photo`) + .set('Authorization', authHeader); + expect(aliasPhotoDeleted.status).toBe(200); + expect(aliasPhotoDeleted.body.data.photoUrl).toBeUndefined(); + const clearedNote = await request(app) .patch(`/v1/moment-logs/${created.body.data.id}`) .set('Authorization', authHeader) @@ -590,6 +635,11 @@ describe('Soundlog API', () => { expect(list.body.data.length).toBeGreaterThan(0); expect(list.body.data.some((item: { id: string }) => item.id === textOnlyMoment.body.data.id)).toBe(false); expect(list.body.data.some((item: { placeName?: string }) => item.placeName === '수정된 테스트 장소')).toBe(true); + + const aliasDeleted = await request(app) + .delete(`/v1/recap-captures/${aliasCreated.body.data.id}`) + .set('Authorization', authHeader); + expect(aliasDeleted.status).toBe(202); }); it('accepts recommendation events', async () => { @@ -667,6 +717,8 @@ describe('Soundlog API', () => { it('handles recap APIs', async () => { const recapSessionId = `recap-session-${Date.now()}`; + const farRecapSessionId = `recap-session-far-${Date.now()}`; + const noLocationRecapSessionId = `recap-session-no-location-${Date.now()}`; await createTestMomentLog({ authHeader, @@ -686,21 +738,211 @@ describe('Soundlog API', () => { sessionId: recapSessionId, trackId: 'seoul-night-track', }); + await createTestMomentLog({ + authHeader, + filename: 'recap-moment-no-location.jpg', + placeName: '위치 없는 리캡 테스트', + sessionId: noLocationRecapSessionId, + trackId: 'seoul-night-track', + }); + await createTestMomentLog({ + authHeader, + filename: 'recap-moment-far.jpg', + lat: 37.57, + lng: 127.02, + placeName: '멀리 있는 공개 리캡', + sessionId: farRecapSessionId, + trackId: 'seoul-night-track', + }); + + const publicNoLocationCreate = await request(app) + .post('/v1/recaps') + .set('Authorization', authHeader) + .send({ + templateId: 'album', + sessionId: noLocationRecapSessionId, + title: '위치 없는 공개 리캡', + visibility: 'public', + }); + expect(publicNoLocationCreate.status).toBe(400); + expect(publicNoLocationCreate.body.error.code).toBe('BAD_REQUEST'); + + const privateNoLocationCreate = await request(app) + .post('/v1/recaps') + .set('Authorization', authHeader) + .send({ + templateId: 'album', + sessionId: noLocationRecapSessionId, + title: '위치 없는 비공개 리캡', + }); + expect(privateNoLocationCreate.status).toBe(201); + const publicNoLocationUpdate = await request(app) + .patch(`/v1/recaps/${privateNoLocationCreate.body.data.id}/visibility`) + .set('Authorization', authHeader) + .send({ visibility: 'public' }); + expect(publicNoLocationUpdate.status).toBe(400); + expect(publicNoLocationUpdate.body.error.code).toBe('BAD_REQUEST'); + + const routePoints = [ + { lat: 37.5512, lng: 126.9882, recordedAt: '2026-07-12T01:00:00.000Z' }, + { lat: 37.5516, lng: 126.9888, recordedAt: '2026-07-12T01:05:00.000Z' }, + { lat: 37.552, lng: 126.989, recordedAt: '2026-07-12T01:10:00.000Z' }, + ]; const idempotencyKey = `recap-${Date.now()}`; const created = await request(app) .post('/v1/recaps') .set('Authorization', authHeader) .set('Idempotency-Key', idempotencyKey) - .send({ templateId: 'album', sessionId: recapSessionId, title: '테스트 리캡' }); + .send({ + routePoints, + 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'); + expect(created.body.data.visibility).toBe('private'); 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 mineMarkers = await request(app) + .get('/v1/recap-markers') + .query({ lat: 37.5512, lng: 126.9882, radiusMeters: 300, scope: 'mine' }) + .set('Authorization', authHeader); + expect(mineMarkers.status).toBe(200); + expect(mineMarkers.body.data.some((marker: { recapId: string }) => marker.recapId === createdRecapId)).toBe(true); + + const publicMarkersBeforeUpdate = await request(app) + .get('/v1/recap-markers') + .query({ lat: 37.5512, lng: 126.9882, radiusMeters: 300, scope: 'public' }) + .set('Authorization', authHeader); + expect(publicMarkersBeforeUpdate.status).toBe(200); + expect( + publicMarkersBeforeUpdate.body.data.some( + (marker: { recapId: string }) => marker.recapId === createdRecapId, + ), + ).toBe(false); + + const visibilityUpdate = await request(app) + .patch(`/v1/recaps/${createdRecapId}/visibility`) + .set('Authorization', authHeader) + .send({ visibility: 'public' }); + expect(visibilityUpdate.status).toBe(200); + expect(visibilityUpdate.body.data.visibility).toBe('public'); + + const farCreated = await request(app) + .post('/v1/recaps') + .set('Authorization', authHeader) + .send({ + templateId: 'album', + sessionId: farRecapSessionId, + title: '300m 밖 공개 리캡', + visibility: 'public', + }); + expect(farCreated.status).toBe(201); + expect(farCreated.body.data.visibility).toBe('public'); + + const otherEmail = `public-log-${Date.now()}@soundlog.test`; + const otherRegister = await request(app).post('/v1/auth/register').send({ + displayName: 'Public Log Traveler', + email: otherEmail, + password: 'soundlog-password', + }); + expect(otherRegister.status).toBe(201); + const otherLogin = await request(app).post('/v1/auth/login').send({ + email: otherEmail, + password: 'soundlog-password', + }); + expect(otherLogin.status).toBe(200); + const otherAuthHeader = `Bearer ${otherLogin.body.data.accessToken}`; + const otherRecapSessionId = `other-recap-session-${Date.now()}`; + + await createTestMomentLog({ + authHeader: otherAuthHeader, + filename: 'other-public-recap.jpg', + lat: 37.5513, + lng: 126.9883, + placeName: '다른 사람 공개 리캡', + sessionId: otherRecapSessionId, + trackId: 'seoul-city', + }); + + const otherPublicCreated = await request(app) + .post('/v1/recaps') + .set('Authorization', otherAuthHeader) + .send({ + templateId: 'album', + sessionId: otherRecapSessionId, + title: '다른 사람 공개 로그', + visibility: 'public', + }); + expect(otherPublicCreated.status).toBe(201); + + const mineList = await request(app) + .get('/v1/recaps') + .query({ scope: 'mine' }) + .set('Authorization', authHeader); + expect(mineList.status).toBe(200); + expect(mineList.body.data.some((recap: { id: string }) => recap.id === createdRecapId)).toBe(true); + expect( + mineList.body.data.some((recap: { id: string }) => recap.id === otherPublicCreated.body.data.id), + ).toBe(false); + + const othersList = await request(app) + .get('/v1/recaps') + .query({ scope: 'others' }) + .set('Authorization', authHeader); + expect(othersList.status).toBe(200); + expect( + othersList.body.data.some((recap: { id: string }) => recap.id === otherPublicCreated.body.data.id), + ).toBe(true); + expect( + othersList.body.data.some((recap: { id: string }) => recap.id === createdRecapId), + ).toBe(false); + + const allList = await request(app) + .get('/v1/recaps') + .query({ scope: 'all' }) + .set('Authorization', authHeader); + expect(allList.status).toBe(200); + expect(allList.body.data.some((recap: { id: string }) => recap.id === createdRecapId)).toBe(true); + expect( + allList.body.data.some((recap: { id: string }) => recap.id === otherPublicCreated.body.data.id), + ).toBe(true); + + const invalidScope = await request(app) + .get('/v1/recaps') + .query({ scope: 'everyone' }) + .set('Authorization', authHeader); + expect(invalidScope.status).toBe(400); + + const publicMarkersAfterUpdate = await request(app) + .get('/v1/recap-markers') + .query({ lat: 37.5512, lng: 126.9882, radiusMeters: 300, scope: 'public' }) + .set('Authorization', authHeader); + expect(publicMarkersAfterUpdate.status).toBe(200); + expect( + publicMarkersAfterUpdate.body.data.some( + (marker: { recapId: string; visibility: string }) => + marker.recapId === createdRecapId && marker.visibility === 'public', + ), + ).toBe(true); + + const fixedRadiusMarkers = await request(app) + .get('/v1/recap-markers') + .query({ lat: 37.5512, lng: 126.9882, radiusMeters: 5000, scope: 'public' }) + .set('Authorization', authHeader); + expect(fixedRadiusMarkers.status).toBe(200); + expect( + fixedRadiusMarkers.body.data.some( + (marker: { recapId: string }) => marker.recapId === farCreated.body.data.id, + ), + ).toBe(false); + const duplicate = await request(app) .post('/v1/recaps') .set('Authorization', authHeader) @@ -715,7 +957,9 @@ describe('Soundlog API', () => { expect(share.status).toBe(200); expect(share.body.data.id).toBe(createdRecapId); expect(share.body.data.trackTitle).toBe(created.body.data.representativeTrack.title); + expect(share.body.data.visibility).toBe('public'); expect(share.body.data.moments.length).toBeGreaterThan(1); + expect(share.body.data.routePoints).toEqual(routePoints); expect(share.body.data.moments.map((moment: { location?: unknown }) => moment.location)).toEqual( expect.arrayContaining([ { lat: 37.5512, lng: 126.9882 }, @@ -723,6 +967,12 @@ describe('Soundlog API', () => { ]), ); + const publicShareAsOther = await request(app) + .get(`/v1/recaps/${createdRecapId}/share`) + .set('Authorization', otherAuthHeader); + expect(publicShareAsOther.status).toBe(200); + expect(publicShareAsOther.body.data.routePoints).toBeUndefined(); + const shareEvent = await request(app) .post(`/v1/recaps/${createdRecapId}/share-events`) .set('Authorization', authHeader) @@ -731,22 +981,51 @@ describe('Soundlog API', () => { }); it('handles travel session APIs', async () => { + const routePoints = [ + { lat: 37.5512, lng: 126.9882, recordedAt: '2026-07-12T02:00:00.000Z' }, + { lat: 37.5516, lng: 126.9888, recordedAt: '2026-07-12T02:03:00.000Z' }, + ]; const created = await request(app) .post('/v1/travel-sessions') .set('Authorization', authHeader) .send({ location: { lat: 37.5512, lng: 126.9882 }, + routePoints, travelMode: 'walk', }); expect(created.status).toBe(201); createdSessionId = created.body.data.id; + expect(created.body.data.routePoints).toEqual(routePoints); + + const synced = await request(app) + .patch(`/v1/travel-sessions/${createdSessionId}`) + .set('Authorization', authHeader) + .send({ + location: { lat: 37.552, lng: 126.989 }, + routePoints: [ + ...routePoints, + { lat: 37.552, lng: 126.989, recordedAt: '2026-07-12T02:08:00.000Z' }, + ], + status: 'active', + }); + expect(synced.status).toBe(200); + expect(synced.body.data.status).toBe('active'); + expect(synced.body.data.routePoints).toHaveLength(3); const updated = await request(app) .patch(`/v1/travel-sessions/${createdSessionId}`) .set('Authorization', authHeader) - .send({ status: 'ended', endedAt: new Date().toISOString() }); + .send({ + routePoints: [ + ...synced.body.data.routePoints, + { lat: 37.5524, lng: 126.9895, recordedAt: '2026-07-12T02:12:00.000Z' }, + ], + status: 'ended', + endedAt: new Date().toISOString(), + }); expect(updated.status).toBe(200); expect(updated.body.data.status).toBe('ended'); + expect(updated.body.data.routePoints).toHaveLength(4); }); it('handles travel rooms, sound map, and mate requests', async () => { diff --git a/tests/mock-soundlog.service.test.ts b/tests/mock-soundlog.service.test.ts index 8c60b37..1a15adb 100644 --- a/tests/mock-soundlog.service.test.ts +++ b/tests/mock-soundlog.service.test.ts @@ -286,6 +286,35 @@ describe('mockSoundlogService', () => { }); expect(share.moments).toHaveLength(1); expect(mockDb.recapShareEvents).toHaveLength(1); + + const otherMoment = await mockSoundlogService.createMomentLog('other-user', { + createdAt: '2026-07-09T10:20:00.000Z', + lat: 37.5446, + lng: 127.0375, + moodTags: ['감성적인'], + photoPath: '/uploads/other-recap.jpg', + placeName: '다른 사람 서울숲', + sessionId: 'other-recap-session', + trackId: 'seoul-city', + }); + const otherPublicRecap = await mockSoundlogService.createRecap('other-user', { + momentLogIds: [requireValue(otherMoment.id)], + sessionId: 'other-recap-session', + title: '다른 사람 공개 로그', + visibility: 'public', + }); + const mineList = await mockSoundlogService.getRecaps(ownerId, { scope: 'mine' }); + const othersList = await mockSoundlogService.getRecaps(ownerId, { scope: 'others' }); + const allList = await mockSoundlogService.getRecaps(ownerId, { scope: 'all' }); + + expect(mineList.data.map((item) => item.id)).toContain(recapId); + expect(mineList.data.map((item) => item.id)).not.toContain(otherPublicRecap.id); + expect(othersList.data.map((item) => item.id)).toContain(otherPublicRecap.id); + expect(othersList.data.map((item) => item.id)).not.toContain(recapId); + expect(allList.data.map((item) => item.id)).toEqual( + expect.arrayContaining([recapId, otherPublicRecap.id]), + ); + await expect( mockSoundlogService.createRecap(ownerId, { representativeTrackId: 'missing-track' }), ).rejects.toSatisfy((error) => { @@ -561,6 +590,10 @@ describe('mockSoundlogService', () => { it('updates travel sessions and returns regional sound trends', async () => { const session = await mockSoundlogService.createTravelSession(ownerId, { location: { lat: 35.1532, lng: 129.1186 }, + routePoints: [ + { lat: 35.1532, lng: 129.1186, recordedAt: '2026-07-09T09:00:00.000Z' }, + { lat: 35.156, lng: 129.119, recordedAt: '2026-07-09T09:20:00.000Z' }, + ], startedAt: '2026-07-09T09:00:00.000Z', travelMode: '바다', }); @@ -575,9 +608,21 @@ describe('mockSoundlogService', () => { lng: 129.1186, radiusMeters: 3000, }); + const synced = await mockSoundlogService.updateTravelSession(ownerId, session.id, { + location: { lat: 35.158, lng: 129.1195 }, + routePoints: [ + ...(session.routePoints ?? []), + { lat: 35.158, lng: 129.1195, recordedAt: '2026-07-09T10:00:00.000Z' }, + ], + status: 'active', + }); const ended = await mockSoundlogService.updateTravelSession(ownerId, session.id, { endedAt: '2026-07-09T11:00:00.000Z', location: { lat: 35.16, lng: 129.12 }, + routePoints: [ + ...(synced.routePoints ?? []), + { lat: 35.16, lng: 129.12, recordedAt: '2026-07-09T10:30:00.000Z' }, + ], status: 'ended', }); const pinsAfterEnding = await mockSoundlogService.getSoundMapPins(ownerId, { @@ -591,9 +636,13 @@ describe('mockSoundlogService', () => { }); expect(session.status).toBe('active'); + expect(session.routePoints).toHaveLength(2); + expect(synced.status).toBe('active'); + expect(synced.routePoints).toHaveLength(3); expect(pinsBeforeEnding.map((pin) => pin.id)).toContain(livePin.id); expect(ended.status).toBe('ended'); expect(ended.endedAt).toBe('2026-07-09T11:00:00.000Z'); + expect(ended.routePoints).toHaveLength(4); expect(pinsAfterEnding.map((pin) => pin.id)).not.toContain(livePin.id); expect(trend.topTracks.length).toBeGreaterThan(0); await expect( From 9d814333f0afbac783be3164b4d24c616bb12629 Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 14 Jul 2026 23:03:11 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EB=A6=AC=EC=BA=A1=C2=B7=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20API=20=EA=B3=84=EC=95=BD=EA=B3=BC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=99=84?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + .github/workflows/pr-check.yml | 3 + AGENTS.md | 8 + Dockerfile | 1 + README.md | 16 +- docker-compose.prod.yml | 2 + docker-compose.yml | 2 + docs/ec2-deployment.md | 2 + docs/recap-log-domain-contract.md | 113 ++ openapi/soundlog-api.yaml | 224 +++- package.json | 2 + .../migration.sql | 72 ++ .../migration.sql | 2 + .../migration.sql | 11 + prisma/models/music.prisma | 6 + prisma/models/recap.prisma | 3 + prisma/models/travel.prisma | 3 + prisma/seed.ts | 784 +++++++++++- scripts/check-live-e2e.mjs | 621 ++++++++++ scripts/check-openapi-route-sync.mjs | 123 ++ scripts/check-public-api-contract.mjs | 101 +- src/config/env.ts | 8 + src/constants/error.constants.ts | 5 + src/controllers/me.controller.ts | 5 + src/controllers/recap.controller.ts | 13 + src/controllers/tour.controller.ts | 8 + src/data/seed-data.ts | 198 +++- src/middlewares/auth.middleware.ts | 9 +- src/mock/mock-db.ts | 10 +- src/routes/index.ts | 22 + src/services/mock-soundlog.service.ts | 730 ++++++++++-- src/services/reverse-geocoding.service.ts | 209 ++++ src/services/soundlog.service.ts | 1050 ++++++++++++++--- src/validators/api.validators.ts | 25 +- tests/api.test.ts | 456 ++++++- tests/mock-soundlog.service.test.ts | 44 +- tests/seed-data.test.ts | 54 + 37 files changed, 4602 insertions(+), 345 deletions(-) create mode 100644 docs/recap-log-domain-contract.md create mode 100644 prisma/migrations/20260713000000_recap_visibility_template_consistency/migration.sql create mode 100644 prisma/migrations/20260714000000_recap_thumbnail_moment/migration.sql create mode 100644 prisma/migrations/20260714001000_mood_recommendation_playlists/migration.sql create mode 100644 scripts/check-live-e2e.mjs create mode 100644 scripts/check-openapi-route-sync.mjs create mode 100644 src/services/reverse-geocoding.service.ts create mode 100644 tests/seed-data.test.ts diff --git a/.env.example b/.env.example index 9e86452..a1bbc09 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ ML_RECOMMENDATION_API_URL=http://211.188.54.204:8000/recommend ML_RECOMMENDATION_TIMEOUT_MS=5000 REQUEST_BODY_LIMIT=1mb MOMENT_PHOTO_MAX_FILE_SIZE_MB=10 +REVERSE_GEOCODING_BASE_URL=https://nominatim.openstreetmap.org +REVERSE_GEOCODING_USER_AGENT=Soundlog/0.1 (+https://github.com/SoundLogTeam/SoundLogServer) TOUR_API_BASE_URL=https://apis.data.go.kr/B551011/KorService2 TOUR_API_SERVICE_KEY= ALLOW_DEV_AUTH_FALLBACK=false diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 4cc918d..92a2b7c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -40,6 +40,9 @@ jobs: - name: Build run: pnpm run build + - name: Check Express and OpenAPI route sync + run: pnpm run check:openapi-sync + - name: Check removed Spotify metadata run: pnpm run check:no-spotify-metadata diff --git a/AGENTS.md b/AGENTS.md index f645175..f2a3699 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,3 +9,11 @@ - Soundlog is a mobile app product. We do not intend to ship or maintain a public web deployment as a product surface. - Server API contracts should prioritize the iOS/Android app experience. Do not add web-specific API behavior unless the user explicitly asks for web support. - Web export or browser checks from the app repository are compatibility checks, not product deployment requirements. + +## Recap And Log Domain + +- Before changing travel sessions, moment logs, recaps, route points, visibility, or map-marker behavior, read `docs/recap-log-domain-contract.md`. +- Product `Recap` means one camera-flow capture. Product `Log` means one or more Recaps sharing exactly one travel `sessionId`. +- A standalone Recap without `sessionId` is not a one-item Log. A travel session with one Recap is a valid Log. +- Log detail responses may contain only that Log's Recaps and route. Never mix other sessions, nearby Recaps, tour places, or live-map pins. +- Prisma `MomentLog` and `Recap` are legacy technical names; follow the mapping in the domain contract. diff --git a/Dockerfile b/Dockerfile index 9b0e11d..0d90e6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,7 @@ COPY --chown=node:node package.json pnpm-lock.yaml ./ COPY --chown=node:node --from=build /app/node_modules ./node_modules COPY --chown=node:node --from=build /app/dist ./dist COPY --chown=node:node --from=build /app/prisma ./prisma +COPY --chown=node:node --from=build /app/src/data ./src/data COPY --chown=node:node openapi ./openapi COPY --chown=node:node scripts/check-public-api-contract.mjs ./scripts/check-public-api-contract.mjs diff --git a/README.md b/README.md index eb3da99..65aacbb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ SoundLog React Native/Expo 앱과 연동되는 Express + TypeScript API 서버입니다. -API 구현 기준은 `SoundLogTeam/api-docs`의 `openapi/soundlog-api.yaml`이며, MVP/확장 endpoint 25개를 제공합니다. +API 구현 기준은 `openapi/soundlog-api.yaml`이며, 현재 Express와 OpenAPI에 동기화된 61개 HTTP 연산을 제공합니다. + +리캡, 여행 로그, 여행 세션, GPS 경로를 변경할 때는 [Recap / Log 서버 도메인 계약](docs/recap-log-domain-contract.md)을 먼저 확인합니다. ## Stack @@ -116,6 +118,7 @@ pnpm dev # 개발 서버 pnpm build # TypeScript build pnpm typecheck # 타입 검사 pnpm test:api # API 테스트 +pnpm check:openapi-sync # Express 라우트와 OpenAPI 메서드/경로 동기화 검사 pnpm check:production-env # 운영 환경변수 점검 pnpm db:migrate # Prisma migration pnpm db:seed # 로컬 seed 데이터 적재 @@ -136,20 +139,29 @@ pnpm db:seed # 로컬 seed 데이터 적재 - `POST /v1/me/migrate-local-data` - Tour / Home / Playlists - `GET /v1/tour/nearby-places` + - `GET /v1/tour/reverse-geocode` - `GET /v1/home/featured-playlists` - `GET /v1/home/mood-recommendations` - `GET /v1/home/recent-music-logs` - `POST /v1/playlists/contextual` - `GET /v1/playlists/:playlistId` - Moment Logs / Library / Recaps + - `GET /v1/recap-captures` + - `POST /v1/recap-captures` + - `PATCH /v1/recap-captures/:momentLogId` + - `DELETE /v1/recap-captures/:momentLogId` + - `PUT /v1/recap-captures/:momentLogId/photo` + - `DELETE /v1/recap-captures/:momentLogId/photo` - `GET /v1/moment-logs` - `POST /v1/moment-logs` - `GET /v1/library/tracks` - `PUT /v1/library/tracks/:trackId` - `POST /v1/recommendation-events` + - `GET /v1/recap-markers` - `GET /v1/recaps` - `POST /v1/recaps` - `GET /v1/recaps/:recapId/share` + - `PATCH /v1/recaps/:recapId/visibility` - `POST /v1/recaps/:recapId/share-events` - Travel Sessions - `POST /v1/travel-sessions`: 서버 기준 여행 모드 시작 @@ -189,4 +201,4 @@ pnpm db:seed # 로컬 seed 데이터 적재 - `GET /v1/home/recent-music-logs` - `POST /v1/playlists/contextual` -> ML 추천 서버 `ML_RECOMMENDATION_API_URL` - `GET /v1/playlists/busan-ocean` - - `GET /v1/recaps/log-1/share` + - `GET /v1/recaps/seoul-night/share` diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 90f9702..025a4fc 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,6 +32,8 @@ services: ML_RECOMMENDATION_TIMEOUT_MS: ${ML_RECOMMENDATION_TIMEOUT_MS:-5000} REQUEST_BODY_LIMIT: ${REQUEST_BODY_LIMIT:-1mb} MOMENT_PHOTO_MAX_FILE_SIZE_MB: ${MOMENT_PHOTO_MAX_FILE_SIZE_MB:-10} + REVERSE_GEOCODING_BASE_URL: ${REVERSE_GEOCODING_BASE_URL:-https://nominatim.openstreetmap.org} + REVERSE_GEOCODING_USER_AGENT: ${REVERSE_GEOCODING_USER_AGENT:-Soundlog/0.1 (+https://github.com/SoundLogTeam/SoundLogServer)} TOUR_API_BASE_URL: ${TOUR_API_BASE_URL:-https://apis.data.go.kr/B551011/KorService2} TOUR_API_SERVICE_KEY: ${TOUR_API_SERVICE_KEY:-} ALLOW_DEV_AUTH_FALLBACK: ${ALLOW_DEV_AUTH_FALLBACK:-false} diff --git a/docker-compose.yml b/docker-compose.yml index 047f1c7..e95bc81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,8 @@ services: ML_RECOMMENDATION_TIMEOUT_MS: ${ML_RECOMMENDATION_TIMEOUT_MS:-5000} REQUEST_BODY_LIMIT: ${REQUEST_BODY_LIMIT:-1mb} MOMENT_PHOTO_MAX_FILE_SIZE_MB: ${MOMENT_PHOTO_MAX_FILE_SIZE_MB:-10} + REVERSE_GEOCODING_BASE_URL: ${REVERSE_GEOCODING_BASE_URL:-https://nominatim.openstreetmap.org} + REVERSE_GEOCODING_USER_AGENT: ${REVERSE_GEOCODING_USER_AGENT:-Soundlog/0.1 (+https://github.com/SoundLogTeam/SoundLogServer)} TOUR_API_BASE_URL: ${TOUR_API_BASE_URL:-https://apis.data.go.kr/B551011/KorService2} TOUR_API_SERVICE_KEY: ${TOUR_API_SERVICE_KEY:-} ALLOW_DEV_AUTH_FALLBACK: ${ALLOW_DEV_AUTH_FALLBACK:-false} diff --git a/docs/ec2-deployment.md b/docs/ec2-deployment.md index 911c815..f08bd80 100644 --- a/docs/ec2-deployment.md +++ b/docs/ec2-deployment.md @@ -42,6 +42,8 @@ ML_RECOMMENDATION_API_URL=http://211.188.54.204:8000/recommend ML_RECOMMENDATION_TIMEOUT_MS=5000 REQUEST_BODY_LIMIT=1mb MOMENT_PHOTO_MAX_FILE_SIZE_MB=10 +REVERSE_GEOCODING_BASE_URL=https://nominatim.openstreetmap.org +REVERSE_GEOCODING_USER_AGENT=Soundlog/0.1 (+https://github.com/SoundLogTeam/SoundLogServer) TOUR_API_BASE_URL=https://apis.data.go.kr/B551011/KorService2 TOUR_API_SERVICE_KEY= ALLOW_DEV_AUTH_FALLBACK=false diff --git a/docs/recap-log-domain-contract.md b/docs/recap-log-domain-contract.md new file mode 100644 index 0000000..00d541e --- /dev/null +++ b/docs/recap-log-domain-contract.md @@ -0,0 +1,113 @@ +# Recap / Log Server Domain Contract + +> Status: Canonical server contract +> Last updated: 2026-07-13 + +Soundlog 제품에서 `Recap`은 카메라 저장 1회로 만든 단일 기록이고, `Log`는 같은 여행모드 세션에서 생성한 Recap의 집합이다. + +```text +Product Recap = one capture +Product Log = Recap[] grouped by exactly one TravelSession +``` + +## Server invariants + +1. Product Recap 하나는 `sessionId`가 없거나 정확히 하나만 가진다. +2. `sessionId`가 없는 Product Recap은 독립 리캡이며 Log가 아니다. +3. `sessionId`가 있는 Product Recap은 그 세션의 Product Log 구성원이다. +4. Product Log는 `sessionId`로 식별한다. 날짜, 장소, 거리, 음악으로 자동 병합하지 않는다. +5. 여행 세션에 Product Recap이 하나만 있어도 Product Log다. +6. Product Recap이 0개인 여행 세션은 Log 목록에 노출할 결과를 만들지 않는다. +7. Log 상세의 `moments`에는 해당 세션의 Product Recap만 포함한다. +8. Log 상세의 `routePoints`에는 해당 세션에서 수집한 GPS 경로만 포함한다. +9. 다른 사용자의 정확한 `routePoints`는 응답하지 않는다. +10. 위치가 없는 Product Recap 또는 Product Log는 public 지도 마커로 전환할 수 없다. +11. Product Recap의 `templateId`와 `visibility`는 원본 `MomentLog`에 저장하며 집계 JSON만 신뢰하지 않는다. +12. 공개 Log의 비소유자 응답은 public 구성 Recap만 포함하고 대표 장소, 이미지, 음악, 개수도 그 공개 구성원에서 다시 계산한다. + +## Legacy technical names + +현재 DB와 API에는 과거 이름이 남아 있다. + +| Product meaning | Server name | Notes | +| --- | --- | --- | +| Recap | Prisma `MomentLog`, `/v1/recap-captures` 또는 `/v1/moment-logs` | 카메라 저장 1회 원본 | +| Log | Prisma `Recap`, `/v1/recaps` | 여행 세션 리캡 집합과 공유 결과. 일반 여행 로그는 `travelSessionId`로 1:1 보장 | +| Travel session | Prisma `TravelSession`, `/v1/travel-sessions` | 로그의 그룹 경계와 경로 수집 상태 | +| GPS route | `routePoints` JSON | 여행모드 중 수집한 경로점 | + +API/DB 호환성 때문에 기술 이름을 즉시 바꾸지 않더라도 제품 의미는 이 표를 따른다. `MomentLog`라는 이름을 근거로 별도의 사용자-facing Moment 개념을 만들면 안 된다. + +## Write flow + +### Standalone Recap + +1. 클라이언트가 `sessionId` 없이 Product Recap을 저장한다. +2. 서버는 독립 리캡 원본을 저장한다. +3. 공유/지도용 서버 `Recap` row가 필요하면 `sessionId = null`로 생성할 수 있다. +4. 이 결과는 Product Log 목록에서 제외한다. + +### Travel Log + +1. `POST /v1/travel-sessions`로 세션을 시작한다. +2. Product Recap 저장 시 활성 세션의 `sessionId`를 전달한다. +3. 세션 중 `routePoints`를 동기화한다. +4. 여행 종료 시 같은 `sessionId`의 Product Recap ID만 사용해 서버 `Recap` row를 생성한다. +5. 서버는 일반 여행 로그의 `Recap.travelSessionId`를 여행 세션과 1:1로 저장하고 외부 DTO에는 `sessionId`로 응답한다. +6. 오프라인에서 만든 로컬 세션은 소유한 Product Recap이 확인될 때 종료 세션으로 복구한 뒤 Log를 만든다. + +## Read flow + +Product Log 목록의 판별 조건: + +```text +Recap.travelSessionId IS NOT NULL +``` + +Product Log 상세 응답: + +- `sessionId`: 로그의 여행 세션 ID +- `moments`: 해당 세션 Product Recap을 촬영 시각 오름차순으로 직렬화 +- `routePoints`: 소유자에게만 제공하는 세션 GPS 경로 +- `momentCount`: 현재 포함된 Product Recap 개수 +- `templateId`: 저장된 표현 결과. 상세 조회에서 편집 계약으로 사용하지 않음 + +`GET /v1/recaps`는 Product Log 목록 전용이며 `sessionId`가 없는 독립 리캡을 반환하지 않는다. 독립 리캡 원본은 `GET /v1/recap-captures`에서 조회하고, 공개·내 지도 핀은 `GET /v1/recap-markers`에서 조회한다. Log 목록과 상세 응답에서는 `sessionId`를 누락하면 안 된다. + +## Log map contract + +Log 상세 지도는 다음 데이터만 사용한다. + +- `moments[].location`: 현재 Log 구성 Recap 핀 +- `routePoints[]`: 현재 Log 여행 세션의 경로선 + +Log 상세 응답에 다음 데이터를 섞지 않는다. + +- 주변 공개 Recap +- 다른 여행 세션의 Recap +- 관광지 추천 핀 +- Live Sound Map 사용자 핀 + +소유자가 아닌 사용자에게는 전체 경로를 반환하지 않는다. 공개 Log를 보여줄 때는 공개가 허용된 Recap 위치만 `moments`에 포함해야 하며 private Recap의 개수나 위치를 추론할 수 있는 데이터도 제외한다. + +## Deletion and consistency + +- Product Recap 삭제 시 관련 Log의 `moments`와 `momentCount`를 갱신한다. +- 구성 Recap이 하나 남아도 Log를 유지한다. +- 구성 Recap이 0개가 되면 Log 목록에서 제외하거나 일관된 삭제 정책을 적용한다. +- 대표 장소, 대표 이미지, 대표 음악이 삭제된 Recap에서 파생됐다면 남은 Recap 기준으로 재계산한다. +- idempotency key로 재시도 중 중복 Product Recap과 중복 Log 생성을 막는다. +- Product Recap 수정, 사진 교체/삭제, 삭제 직후 같은 트랜잭션에서 대표 장소, 이미지, 음악, 개수, 공개 상태를 재계산한다. +- 마지막 Product Recap이 삭제되면 집계 Log row를 삭제한다. + +## Verification checklist + +- [x] `sessionId = null` 독립 리캡이 Product Log 목록에서 제외되는가? +- [x] 리캡 1개짜리 여행 세션도 `sessionId`가 있는 Log로 응답하는가? +- [x] Log 상세 `moments`가 모두 Log의 `sessionId`와 일치하는가? +- [x] Log 상세 `routePoints`가 다른 세션 경로와 섞이지 않는가? +- [x] 비소유자 상세 응답에서 `routePoints`가 제거되는가? +- [x] public 전환 시 공개 구성 리캡과 위치 존재를 검증하는가? +- [x] 삭제 후 `momentCount`와 대표 정보가 실제 구성원과 일치하는가? + +전체 제품/UI 규칙은 프론트엔드 저장소의 `docs/product/RECAP_LOG_DOMAIN_MODEL.md`를 함께 따른다. diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index a4c7aa3..85f35a3 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -37,7 +37,7 @@ tags: - name: RecapCaptures description: 카메라로 남기는 단발성 리캡 저장 API - name: Recaps - description: 여행 로그와 공개/낱개 리캡 리스트 및 공유 콘텐츠 + description: 여행 로그 목록, 리캡 지도 마커 및 공유 콘텐츠 - name: Library description: 좋아요/저장 음악 보관함 - name: RecommendationEvents @@ -226,6 +226,32 @@ paths: $ref: "#/components/schemas/MeResponse" "401": $ref: "#/components/responses/Unauthorized" + delete: + tags: + - Me + summary: 내 계정과 연결 데이터 즉시 삭제 + description: 사용자 계정, 토큰, 프로필, 여행 기록, 리캡, 보관함 및 커뮤니티 데이터를 삭제합니다. + operationId: deleteMyAccount + responses: + "200": + description: 계정 삭제 완료 + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: object + required: + - deleted + properties: + deleted: + type: boolean + const: true + "401": + $ref: "#/components/responses/Unauthorized" /v1/me/profile: get: @@ -297,6 +323,35 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + /v1/tour/places: + get: + tags: + - Tour + summary: 수동 장소 검색 + description: 위치 권한 없이 사용자가 입력한 장소명, 주소 또는 카테고리로 Soundlog 장소를 검색합니다. + operationId: searchPlaces + parameters: + - name: query + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 80 + example: 광안리 + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: 검색된 장소 목록 + content: + application/json: + schema: + $ref: "#/components/schemas/PlaceContextListResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/tour/nearby-places: get: tags: @@ -336,6 +391,31 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + /v1/tour/reverse-geocode: + get: + tags: + - Tour + summary: 현재 좌표의 한국어 지역명 조회 + description: | + 주변 관광공사 장소가 없을 때 사용할 한국어 지역명과 주소를 반환합니다. + 서버에서 OpenStreetMap Nominatim을 프록시하고 동일 좌표 결과를 캐시합니다. + 결과가 없거나 외부 서비스가 응답하지 않으면 `data`는 `null`입니다. + operationId: reverseGeocodeLocation + parameters: + - $ref: "#/components/parameters/Lat" + - $ref: "#/components/parameters/Lng" + responses: + "200": + description: 한국어 지역명 또는 null + content: + application/json: + schema: + $ref: "#/components/schemas/PlaceContextNullableResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/home/featured-playlists: get: tags: @@ -386,27 +466,20 @@ paths: tags: - Home summary: 홈 무드 추천 카드 조회 - description: 선택된 상단 필터, 무드 필터, 온보딩 취향을 기반으로 추천 카드를 정렬합니다. + description: 선택한 무드와 정확히 일치하는 카드를 조회하고, 온보딩 취향을 기반으로 결과를 정렬합니다. 전체를 선택하면 모든 카드를 반환합니다. operationId: getMoodRecommendations parameters: - - name: topFilter - in: query - required: true - schema: - type: string - default: 전체 - example: 청량한 - name: moodFilter in: query required: true schema: type: string default: 전체 - example: 청량한 + example: 시원한 - name: recommendationMode in: query required: true - description: 홈 상단 토글에서 선택한 추천 모드. + description: 음악추천 화면의 추천 모드. schema: $ref: "#/components/schemas/MusicRecommendationMode" example: everyday @@ -421,7 +494,7 @@ paths: description: 온보딩 선호 무드. 쉼표 구분. schema: type: string - example: "청량한,잔잔한" + example: "시원한,잔잔한" - name: travelStyles in: query description: 여행 스타일. 쉼표 구분. @@ -1036,7 +1109,7 @@ paths: tags: - Recaps summary: 여행모드 지도 리캡 마커 조회 - description: 현재 위치 기준 300m 이내의 공개 리캡 또는 내 리캡 마커를 조회합니다. `radiusMeters`는 이전 클라이언트 호환을 위해 받을 수 있지만 서버 정책상 항상 300m로 고정됩니다. + description: "`scope=public`은 현재 위치 기준 300m 이내의 공개 리캡을 조회합니다. `scope=mine`은 위치와 공개 여부에 관계없이 좌표가 있는 내 전체 리캡 마커를 조회하며 `lat`, `lng`, `radiusMeters`를 적용하지 않습니다." operationId: getRecapMarkers parameters: - name: lat @@ -1054,7 +1127,7 @@ paths: - name: radiusMeters in: query deprecated: true - description: 이전 클라이언트 호환용 값입니다. 전달해도 서버는 항상 300m 기준으로 필터링합니다. + description: 공개 리캡의 이전 클라이언트 호환용 값입니다. `scope=public`은 전달값과 관계없이 300m, `scope=mine`은 거리 제한 없이 조회합니다. required: false schema: type: integer @@ -1084,8 +1157,8 @@ paths: get: tags: - Recaps - summary: 로그/리캡 리스트 조회 - description: 로그 탭에서 여행모드 리캡 묶음인 여행 로그, 여행모드 밖에서 만든 낱개 리캡, 공개 리캡을 리스트 형태로 보여줍니다. `mine`은 내 로그, `others`는 다른 사용자의 전체공개 로그, `all`은 내 로그와 다른 사용자의 전체공개 로그를 함께 반환합니다. + summary: 여행 로그 리스트 조회 + description: 여행모드에서 생성된 리캡 묶음만 로그 단위로 반환합니다. `sessionId`가 없는 독립 리캡은 로그 목록에서 제외하며 `GET /v1/recap-captures` 또는 지도 마커 API에서 조회합니다. `mine`은 내 로그, `others`는 다른 사용자의 전체공개 로그, `all`은 내 로그와 다른 사용자의 전체공개 로그를 함께 반환합니다. operationId: getRecaps parameters: - $ref: "#/components/parameters/Cursor" @@ -1193,6 +1266,39 @@ paths: "404": $ref: "#/components/responses/NotFound" + /v1/recaps/{recapId}/thumbnail: + patch: + tags: + - Recaps + summary: 로그 썸네일 리캡 지정 + description: 내 여행 로그에 포함된 리캡 하나를 대표 썸네일로 지정합니다. 지정하지 않은 로그는 시간순 첫 리캡을 기본 썸네일로 사용합니다. + operationId: updateRecapThumbnail + parameters: + - name: recapId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RecapThumbnailUpdateRequest" + responses: + "200": + description: 썸네일이 변경된 여행 로그 + content: + application/json: + schema: + $ref: "#/components/schemas/RecapItemResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /v1/recaps/{recapId}/share-events: post: tags: @@ -2225,12 +2331,17 @@ components: overview: type: string example: 부산을 대표하는 도심 해변 관광지입니다. + attribution: + type: string + description: 외부 위치 데이터 표시 의무가 있을 때 사용하는 출처 문구 + example: © OpenStreetMap contributors source: type: string enum: - tour-api - seed - user + - reverse-geocode Track: type: object @@ -2330,12 +2441,16 @@ components: example: - 팝 - K-POP + imageUrl: + type: string + format: uri + description: 무드 추천 카드 대표 이미지. moods: type: array items: type: string example: - - 청량한 + - 시원한 - 신나는 travelStyles: type: array @@ -2553,9 +2668,6 @@ components: - seed-fallback - server-contextual example: ml-recommendation - topFilter: - type: string - example: 전체 travelMode: $ref: "#/components/schemas/TravelMode" @@ -2662,6 +2774,11 @@ components: format: date-time sessionId: type: string + templateId: + $ref: "#/components/schemas/RecapTemplateId" + visibility: + $ref: "#/components/schemas/RecapVisibility" + description: 리캡 단위 공개 범위입니다. 기본값은 private이며 public은 lat/lng가 함께 있어야 합니다. lat: type: number format: double @@ -2711,6 +2828,10 @@ components: sessionId: type: string nullable: true + templateId: + $ref: "#/components/schemas/RecapTemplateId" + visibility: + $ref: "#/components/schemas/RecapVisibility" lat: type: number format: double @@ -2755,7 +2876,9 @@ components: - id - createdAt - moodTags + - recapVisibility - syncStatus + - templateId properties: id: type: string @@ -2768,6 +2891,10 @@ components: format: date-time sessionId: type: string + templateId: + $ref: "#/components/schemas/RecapTemplateId" + recapVisibility: + $ref: "#/components/schemas/RecapVisibility" location: $ref: "#/components/schemas/GeoPoint" placeCategory: @@ -2865,7 +2992,7 @@ components: RecapItem: type: object - description: 로그 탭에 표시되는 여행 로그 또는 낱개 리캡 요약입니다. + description: 로그 탭에 표시되는 여행 로그 요약입니다. 응답에는 항상 여행 세션을 식별하는 sessionId가 포함됩니다. required: - id - title @@ -2890,8 +3017,15 @@ components: momentCount: type: integer example: 8 + backgroundImageUrl: + type: string + format: uri + description: 선택한 리캡의 로그 썸네일 이미지입니다. 선택값이 없으면 첫 리캡 이미지입니다. sessionId: type: string + thumbnailMomentId: + type: string + description: 로그 썸네일로 선택된 리캡 캡처 ID입니다. visibility: $ref: "#/components/schemas/RecapVisibility" @@ -2902,7 +3036,6 @@ components: - film - lp - map - - video RecapVisibility: type: string @@ -2913,14 +3046,21 @@ components: RecapCreateRequest: type: object - description: 여행 로그 생성 요청입니다. visibility가 public이면 대표 위치를 만들 수 있는 리캡 캡처 위치가 필요합니다. + description: 여행 로그 또는 독립 리캡 집계 생성 요청입니다. 여행 로그는 sessionId가 필요하고, 독립 리캡은 세션에 속하지 않은 momentLogIds 정확히 1개가 필요합니다. 공개 로그는 위치가 있고 public인 구성 리캡을 1개 이상 포함해야 합니다. 로컬 여행 세션 ID는 소유한 구성 리캡이 확인되면 종료 세션으로 복구됩니다. required: - templateId + anyOf: + - required: + - sessionId + - required: + - momentLogIds properties: sessionId: type: string momentLogIds: type: array + minItems: 1 + uniqueItems: true items: type: string templateId: @@ -2940,13 +3080,22 @@ components: RecapVisibilityUpdateRequest: type: object - description: public으로 변경하려면 해당 리캡에 대표 위치가 있어야 합니다. + description: 여행 로그를 public으로 변경하려면 위치가 있고 public인 구성 리캡이 1개 이상 있어야 합니다. 독립 리캡은 자신의 공개 범위도 함께 갱신됩니다. required: - visibility properties: visibility: $ref: "#/components/schemas/RecapVisibility" + RecapThumbnailUpdateRequest: + type: object + required: + - momentId + properties: + momentId: + type: string + description: 해당 여행 로그에 포함된 리캡 캡처 ID입니다. + RecapMapMarker: type: object required: @@ -3027,6 +3176,12 @@ components: recordedAt: type: string format: date-time + templateId: + $ref: "#/components/schemas/RecapTemplateId" + track: + $ref: "#/components/schemas/Track" + visibility: + $ref: "#/components/schemas/RecapVisibility" RecapShare: type: object @@ -3039,6 +3194,9 @@ components: properties: id: type: string + isMine: + type: boolean + description: 현재 인증 사용자가 이 리캡의 작성자인지 여부입니다. placeName: type: string trackTitle: @@ -3066,6 +3224,14 @@ components: shareImageUrl: type: string format: uri + sessionId: + type: string + description: 여행모드에서 생성된 리캡이 연결된 여행 세션 ID입니다. + templateId: + $ref: "#/components/schemas/RecapTemplateId" + thumbnailMomentId: + type: string + description: 현재 사용자에게 노출 가능한 대표 썸네일 리캡 ID입니다. visibility: $ref: "#/components/schemas/RecapVisibility" @@ -3885,6 +4051,16 @@ components: items: $ref: "#/components/schemas/PlaceContext" + PlaceContextNullableResponse: + type: object + required: + - data + properties: + data: + nullable: true + allOf: + - $ref: "#/components/schemas/PlaceContext" + FeaturedPlaylistListResponse: type: object required: diff --git a/package.json b/package.json index 27ab3f7..a4158b8 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "test": "vitest", "test:api": "vitest run", "test:coverage": "vitest run --coverage", + "check:openapi-sync": "node scripts/check-openapi-route-sync.mjs", "check:no-spotify-metadata": "node scripts/check-no-spotify-metadata.mjs", "check:public-api-contract": "node scripts/check-public-api-contract.mjs", + "check:live-e2e": "node scripts/check-live-e2e.mjs", "check:production-env": "node scripts/check-production-env.mjs", "db:generate": "prisma generate --schema prisma", "db:migrate": "prisma migrate dev --schema prisma", diff --git a/prisma/migrations/20260713000000_recap_visibility_template_consistency/migration.sql b/prisma/migrations/20260713000000_recap_visibility_template_consistency/migration.sql new file mode 100644 index 0000000..c0be5ae --- /dev/null +++ b/prisma/migrations/20260713000000_recap_visibility_template_consistency/migration.sql @@ -0,0 +1,72 @@ +ALTER TABLE "MomentLog" +ADD COLUMN "templateId" TEXT NOT NULL DEFAULT 'album', +ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'private'; + +UPDATE "MomentLog" AS moment +SET + "templateId" = recap."templateId", + "visibility" = CASE + WHEN recap."visibility" = 'public' THEN 'public' + ELSE moment."visibility" + END +FROM "Recap" AS recap +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(recap."moments", '[]'::jsonb)) AS recap_moment + WHERE recap_moment ->> 'id' = moment."id" +); + +UPDATE "Recap" AS recap +SET "moments" = enriched."moments" +FROM ( + SELECT + source_recap."id", + jsonb_agg( + recap_moment || jsonb_build_object( + 'templateId', COALESCE(moment."templateId", source_recap."templateId"), + 'visibility', COALESCE(moment."visibility", 'private') + ) + ORDER BY recap_moment ->> 'recordedAt' + ) AS "moments" + FROM "Recap" AS source_recap + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(source_recap."moments", '[]'::jsonb)) AS recap_moment + LEFT JOIN "MomentLog" AS moment ON moment."id" = recap_moment ->> 'id' + GROUP BY source_recap."id" +) AS enriched +WHERE recap."id" = enriched."id"; + +ALTER TABLE "Recap" ADD COLUMN "travelSessionId" TEXT; + +WITH ranked_logs AS ( + SELECT + recap."id", + recap."sessionId", + ROW_NUMBER() OVER ( + PARTITION BY recap."sessionId" + ORDER BY recap."createdAt" DESC, recap."id" DESC + ) AS rank + FROM "Recap" AS recap + INNER JOIN "TravelSession" AS session + ON recap."sessionId" = session."id" + AND recap."userId" = session."userId" + WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(recap."moments", '[]'::jsonb)) AS recap_moment + INNER JOIN "MomentLog" AS moment + ON moment."id" = recap_moment ->> 'id' + AND moment."sessionId" = recap."sessionId" + AND moment."userId" = recap."userId" + ) +) +UPDATE "Recap" AS recap +SET "travelSessionId" = ranked_logs."sessionId" +FROM ranked_logs +WHERE recap."id" = ranked_logs."id" + AND ranked_logs.rank = 1; + +CREATE UNIQUE INDEX "Recap_travelSessionId_key" ON "Recap"("travelSessionId"); + +ALTER TABLE "Recap" +ADD CONSTRAINT "Recap_travelSessionId_fkey" +FOREIGN KEY ("travelSessionId") REFERENCES "TravelSession"("id") +ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260714000000_recap_thumbnail_moment/migration.sql b/prisma/migrations/20260714000000_recap_thumbnail_moment/migration.sql new file mode 100644 index 0000000..dbc0ab2 --- /dev/null +++ b/prisma/migrations/20260714000000_recap_thumbnail_moment/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Recap" +ADD COLUMN "thumbnailMomentId" TEXT; diff --git a/prisma/migrations/20260714001000_mood_recommendation_playlists/migration.sql b/prisma/migrations/20260714001000_mood_recommendation_playlists/migration.sql new file mode 100644 index 0000000..6762495 --- /dev/null +++ b/prisma/migrations/20260714001000_mood_recommendation_playlists/migration.sql @@ -0,0 +1,11 @@ +ALTER TABLE "MoodRecommendation" +ADD COLUMN "imageUrl" TEXT, +ADD COLUMN "playlistId" TEXT; + +CREATE INDEX "MoodRecommendation_playlistId_idx" +ON "MoodRecommendation"("playlistId"); + +ALTER TABLE "MoodRecommendation" +ADD CONSTRAINT "MoodRecommendation_playlistId_fkey" +FOREIGN KEY ("playlistId") REFERENCES "Playlist"("id") +ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/models/music.prisma b/prisma/models/music.prisma index df4186c..d0998df 100644 --- a/prisma/models/music.prisma +++ b/prisma/models/music.prisma @@ -30,6 +30,7 @@ model Playlist { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tracks PlaylistTrack[] + moodRecommendations MoodRecommendation[] } model PlaylistTrack { @@ -53,9 +54,14 @@ model MoodRecommendation { genres String[] @default([]) moods String[] @default([]) travelStyles String[] @default([]) + imageUrl String? + playlistId String? trackId String sortOrder Int @default(0) + playlist Playlist? @relation(fields: [playlistId], references: [id], onDelete: SetNull) track Track @relation(fields: [trackId], references: [id]) + + @@index([playlistId]) } model LibraryTrackState { diff --git a/prisma/models/recap.prisma b/prisma/models/recap.prisma index e22e179..27129d4 100644 --- a/prisma/models/recap.prisma +++ b/prisma/models/recap.prisma @@ -7,7 +7,9 @@ model Recap { createdAt DateTime @default(now()) momentCount Int? sessionId String? + travelSessionId String? @unique backgroundImageUrl String? + thumbnailMomentId String? discImageUrl String? recordedAt DateTime? shareImageUrl String? @@ -18,6 +20,7 @@ model Recap { lat Float? lng Float? user User @relation(fields: [userId], references: [id], onDelete: Cascade) + travelSession TravelSession? @relation(fields: [travelSessionId], references: [id], onDelete: SetNull) representativeTrack Track @relation("RepresentativeTrack", fields: [representativeTrackId], references: [id]) shareEvents RecapShareEvent[] } diff --git a/prisma/models/travel.prisma b/prisma/models/travel.prisma index 6ca8419..d4b487c 100644 --- a/prisma/models/travel.prisma +++ b/prisma/models/travel.prisma @@ -27,10 +27,12 @@ model MomentLog { placeName String? note String? trackSnapshot Json? + templateId String @default("album") travelMode String? moodTags String[] source String syncStatus String + visibility String @default("private") user User @relation(fields: [userId], references: [id], onDelete: Cascade) } @@ -45,4 +47,5 @@ model TravelSession { lng Float? routePoints Json? user User @relation(fields: [userId], references: [id], onDelete: Cascade) + recap Recap? } diff --git a/prisma/seed.ts b/prisma/seed.ts index 0ce5d50..dfd4b38 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, type Track } from '@prisma/client'; import { defaultUser, @@ -13,6 +13,530 @@ import { const prisma = new PrismaClient(); +type DemoCaptureSeed = { + id: string; + imageUrl: string; + lat: number; + lng: number; + moodTags: string[]; + note: string; + placeName: string; + recordedAt: string; + templateId: 'album' | 'film' | 'lp' | 'map'; + trackId: string; + visibility: 'private' | 'public'; +}; + +type DemoUserSeed = { + companionType: string; + displayName: string; + preferredGenres: string[]; + preferredMoods: string[]; + providerUserId: string; + travelStyles: string[]; +}; + +type DemoTravelLogSeed = { + captures: DemoCaptureSeed[]; + id: string; + placeName: string; + sessionId: string; + templateId: DemoCaptureSeed['templateId']; + title: string; + travelMode: string; + userProviderId: string; +}; + +const demoImages = { + busanBeach: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + busanCoast: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + cityNight: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + cityWalk: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + islandRoad: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', +}; + +const demoCommunityUsers: DemoUserSeed[] = [ + { + companionType: 'friends', + displayName: '민지', + preferredGenres: ['K-POP', 'R&B'], + preferredMoods: ['설레는', '감성적인'], + providerUserId: 'demo-minji', + travelStyles: ['야경', '카페'], + }, + { + companionType: 'solo', + displayName: '준호', + preferredGenres: ['인디', '록'], + preferredMoods: ['시원한', '신나는'], + providerUserId: 'demo-junho', + travelStyles: ['바다', '드라이브'], + }, + { + companionType: 'couple', + displayName: '서연', + preferredGenres: ['발라드', '인디'], + preferredMoods: ['잔잔한', '감성적인'], + providerUserId: 'demo-seoyeon', + travelStyles: ['산책', '사진'], + }, + { + companionType: 'family', + displayName: '도윤', + preferredGenres: ['K-POP', '팝'], + preferredMoods: ['신나는', '시원한'], + providerUserId: 'demo-doyoon', + travelStyles: ['드라이브', '맛집'], + }, +]; + +const demoTravelLogs: DemoTravelLogSeed[] = [ + { + id: 'demo-log-minji-seoul-night', + placeName: '서울', + sessionId: 'demo-session-minji-seoul-night', + templateId: 'film', + title: '민지의 서울 야경 산책', + travelMode: 'walk', + userProviderId: 'demo-minji', + captures: [ + { + id: 'demo-capture-minji-namsan', + imageUrl: demoImages.cityNight, + lat: 37.5512, + lng: 126.9882, + moodTags: ['설레는', '감성적인'], + note: '해가 지기 시작한 남산에서 첫 곡을 골랐다.', + placeName: '남산서울타워', + recordedAt: '2026-07-05T10:10:00.000Z', + templateId: 'film', + trackId: 'seoul-city', + visibility: 'public', + }, + { + id: 'demo-capture-minji-haebangchon', + imageUrl: demoImages.cityWalk, + lat: 37.5425, + lng: 126.987, + moodTags: ['잔잔한'], + note: '골목을 천천히 걷던 조용한 시간.', + placeName: '해방촌', + recordedAt: '2026-07-05T10:42:00.000Z', + templateId: 'album', + trackId: 'night-letter', + visibility: 'public', + }, + { + id: 'demo-capture-minji-itaewon', + imageUrl: demoImages.cityNight, + lat: 37.5345, + lng: 126.9946, + moodTags: ['신나는'], + note: '불빛이 켜진 거리에서 여행을 마무리했다.', + placeName: '이태원', + recordedAt: '2026-07-05T11:18:00.000Z', + templateId: 'map', + trackId: 'seoul-night-track', + visibility: 'public', + }, + ], + }, + { + id: 'demo-log-minji-seongsu', + placeName: '성수', + sessionId: 'demo-session-minji-seongsu', + templateId: 'album', + title: '성수 카페와 서울숲', + travelMode: 'cafe', + userProviderId: 'demo-minji', + captures: [ + { + id: 'demo-capture-minji-seoulforest', + imageUrl: demoImages.cityWalk, + lat: 37.5444, + lng: 127.0374, + moodTags: ['잔잔한', '시원한'], + note: '나무 사이로 바람이 좋았던 오후.', + placeName: '서울숲', + recordedAt: '2026-07-08T05:20:00.000Z', + templateId: 'album', + trackId: 'hangang', + visibility: 'public', + }, + { + id: 'demo-capture-minji-seongsu-cafe', + imageUrl: demoImages.cityNight, + lat: 37.5447, + lng: 127.0557, + moodTags: ['감성적인'], + note: '창가에 앉아 한 곡을 반복해서 들었다.', + placeName: '성수 카페거리', + recordedAt: '2026-07-08T06:05:00.000Z', + templateId: 'lp', + trackId: 'geoje-seasons', + visibility: 'public', + }, + ], + }, + { + id: 'demo-log-junho-san-francisco', + placeName: '샌프란시스코', + sessionId: 'demo-session-junho-san-francisco', + templateId: 'map', + title: '샌프란시스코 도심 산책', + travelMode: 'walk', + userProviderId: 'demo-junho', + captures: [ + { + id: 'demo-capture-junho-union-square', + imageUrl: demoImages.cityWalk, + lat: 37.7877, + lng: -122.4075, + moodTags: ['설레는'], + note: '도심 산책을 시작하며 고른 첫 곡.', + placeName: '유니언 스퀘어', + recordedAt: '2026-07-13T02:10:00.000Z', + templateId: 'album', + trackId: 'seoul-city', + visibility: 'public', + }, + { + id: 'demo-capture-junho-yerba-buena', + imageUrl: demoImages.cityNight, + lat: 37.7859, + lng: -122.4042, + moodTags: ['잔잔한'], + note: '공원 벤치에서 잠깐 쉬어간 시간.', + placeName: '예르바 부에나 가든', + recordedAt: '2026-07-13T02:38:00.000Z', + templateId: 'film', + trackId: 'hangang', + visibility: 'public', + }, + { + id: 'demo-capture-junho-market-street', + imageUrl: demoImages.cityNight, + lat: 37.7851, + lng: -122.407, + moodTags: ['감성적인'], + note: '도시의 불빛과 음악으로 산책을 마무리했다.', + placeName: '마켓 스트리트', + recordedAt: '2026-07-13T03:04:00.000Z', + templateId: 'map', + trackId: 'moon-seoul', + visibility: 'public', + }, + ], + }, + { + id: 'demo-log-junho-busan-ocean', + placeName: '부산', + sessionId: 'demo-session-junho-busan-ocean', + templateId: 'map', + title: '부산 해변 드라이브', + travelMode: 'drive', + userProviderId: 'demo-junho', + captures: [ + { + id: 'demo-capture-junho-gwangalli', + imageUrl: demoImages.busanBeach, + lat: 35.1532, + lng: 129.1186, + moodTags: ['시원한', '신나는'], + note: '광안대교가 보이기 시작한 순간.', + placeName: '광안리해수욕장', + recordedAt: '2026-07-09T08:05:00.000Z', + templateId: 'map', + trackId: 'geoje-travel', + visibility: 'public', + }, + { + id: 'demo-capture-junho-dalmaji', + imageUrl: demoImages.busanCoast, + lat: 35.1588, + lng: 129.1719, + moodTags: ['시원한'], + note: '창문을 열고 달맞이길을 달렸다.', + placeName: '달맞이길', + recordedAt: '2026-07-09T08:44:00.000Z', + templateId: 'film', + trackId: 'geoje-summer', + visibility: 'private', + }, + { + id: 'demo-capture-junho-haeundae', + imageUrl: demoImages.busanCoast, + lat: 35.1587, + lng: 129.1604, + moodTags: ['감성적인'], + note: '해운대의 늦은 밤과 잘 맞았던 노래.', + placeName: '해운대해수욕장', + recordedAt: '2026-07-09T09:30:00.000Z', + templateId: 'lp', + trackId: 'geoje-everything', + visibility: 'public', + }, + ], + }, + { + id: 'demo-log-seoyeon-jeju-walk', + placeName: '제주', + sessionId: 'demo-session-seoyeon-jeju-walk', + templateId: 'album', + title: '제주 동쪽의 느린 하루', + travelMode: 'walk', + userProviderId: 'demo-seoyeon', + captures: [ + { + id: 'demo-capture-seoyeon-seongsan', + imageUrl: demoImages.islandRoad, + lat: 33.4581, + lng: 126.9425, + moodTags: ['잔잔한'], + note: '성산일출봉 아래에서 들은 첫 곡.', + placeName: '성산일출봉', + recordedAt: '2026-07-10T00:20:00.000Z', + templateId: 'album', + trackId: 'geoje-tree', + visibility: 'public', + }, + { + id: 'demo-capture-seoyeon-seopjikoji', + imageUrl: demoImages.busanBeach, + lat: 33.4249, + lng: 126.9294, + moodTags: ['시원한', '감성적인'], + note: '바람이 세게 불어도 계속 걷고 싶었다.', + placeName: '섭지코지', + recordedAt: '2026-07-10T01:32:00.000Z', + templateId: 'film', + trackId: 'geoje-wi-ing', + visibility: 'public', + }, + { + id: 'demo-capture-seoyeon-woljeongri', + imageUrl: demoImages.busanCoast, + lat: 33.5565, + lng: 126.7958, + moodTags: ['설레는'], + note: '월정리에서 바다를 오래 바라봤다.', + placeName: '월정리해변', + recordedAt: '2026-07-10T03:15:00.000Z', + templateId: 'map', + trackId: 'geoje-seasons', + visibility: 'public', + }, + ], + }, + { + id: 'demo-log-doyoon-gyeongju', + placeName: '경주', + sessionId: 'demo-session-doyoon-gyeongju', + templateId: 'lp', + title: '경주 밤 드라이브', + travelMode: 'drive', + userProviderId: 'demo-doyoon', + captures: [ + { + id: 'demo-capture-doyoon-daereungwon', + imageUrl: demoImages.cityWalk, + lat: 35.838, + lng: 129.2122, + moodTags: ['잔잔한'], + note: '대릉원 돌담길을 따라 천천히 걸었다.', + placeName: '대릉원', + recordedAt: '2026-07-11T09:15:00.000Z', + templateId: 'film', + trackId: 'moon-seoul', + visibility: 'public', + }, + { + id: 'demo-capture-doyoon-cheomseongdae', + imageUrl: demoImages.cityNight, + lat: 35.8347, + lng: 129.2191, + moodTags: ['감성적인'], + note: '첨성대에 불이 켜진 뒤의 풍경.', + placeName: '첨성대', + recordedAt: '2026-07-11T09:48:00.000Z', + templateId: 'lp', + trackId: 'night-letter', + visibility: 'public', + }, + { + id: 'demo-capture-doyoon-donggung', + imageUrl: demoImages.cityNight, + lat: 35.8341, + lng: 129.2266, + moodTags: ['설레는', '신나는'], + note: '물에 비친 야경을 마지막 리캡으로 남겼다.', + placeName: '동궁과 월지', + recordedAt: '2026-07-11T10:24:00.000Z', + templateId: 'map', + trackId: 'seoul-night-track', + visibility: 'public', + }, + ], + }, +]; + +const demoStandaloneRecaps: Array = [ + { + id: 'demo-standalone-minji-gwanghwamun', + imageUrl: demoImages.cityWalk, + lat: 37.5759, + lng: 126.9768, + moodTags: ['잔잔한'], + note: '광화문을 지나며 잠깐 멈춘 순간.', + placeName: '광화문광장', + recordedAt: '2026-07-12T03:12:00.000Z', + templateId: 'film', + trackId: 'seoul-city', + userProviderId: 'demo-minji', + visibility: 'public', + }, + { + id: 'demo-standalone-seoyeon-namsan', + imageUrl: demoImages.cityNight, + lat: 37.5513, + lng: 126.9883, + moodTags: ['감성적인', '잔잔한'], + note: '서울 야경이 시작되는 시간의 음악.', + placeName: '남산서울타워', + recordedAt: '2026-07-12T04:20:00.000Z', + templateId: 'map', + trackId: 'seoul-city', + userProviderId: 'demo-seoyeon', + visibility: 'public', + }, + { + id: 'demo-standalone-junho-gwangalli', + imageUrl: demoImages.busanBeach, + lat: 35.1534, + lng: 129.1188, + moodTags: ['시원한'], + note: '파도 소리와 함께 남긴 한 장.', + placeName: '광안리해수욕장', + recordedAt: '2026-07-12T06:40:00.000Z', + templateId: 'map', + trackId: 'geoje-travel', + userProviderId: 'demo-junho', + visibility: 'public', + }, + { + id: 'demo-standalone-seoyeon-seongsu', + imageUrl: demoImages.cityNight, + lat: 37.5448, + lng: 127.0558, + moodTags: ['감성적인'], + note: '저녁이 된 성수의 작은 카페.', + placeName: '성수 카페거리', + recordedAt: '2026-07-12T10:08:00.000Z', + templateId: 'album', + trackId: 'geoje-seasons', + userProviderId: 'demo-seoyeon', + visibility: 'public', + }, + { + id: 'demo-standalone-doyoon-cheomseongdae', + imageUrl: demoImages.cityNight, + lat: 35.8348, + lng: 129.2192, + moodTags: ['설레는'], + note: '경주에서 발견한 오늘의 사운드.', + placeName: '첨성대', + recordedAt: '2026-07-12T11:00:00.000Z', + templateId: 'lp', + trackId: 'night-letter', + userProviderId: 'demo-doyoon', + visibility: 'public', + }, + { + id: 'demo-standalone-doyoon-noksapyeong', + imageUrl: demoImages.cityNight, + lat: 37.53485, + lng: 126.99485, + moodTags: ['감성적인'], + note: '서울의 불빛이 한눈에 들어온 순간.', + placeName: '녹사평 전망대', + recordedAt: '2026-07-13T11:10:00.000Z', + templateId: 'map', + trackId: 'seoul-night-track', + userProviderId: 'demo-doyoon', + visibility: 'public', + }, + { + id: 'demo-standalone-seoyeon-haebangchon', + imageUrl: demoImages.cityWalk, + lat: 37.53555, + lng: 126.99415, + moodTags: ['잔잔한'], + note: '골목을 걷다가 노래와 풍경이 잘 맞았다.', + placeName: '해방촌 오거리', + recordedAt: '2026-07-13T11:22:00.000Z', + templateId: 'film', + trackId: 'night-letter', + userProviderId: 'demo-seoyeon', + visibility: 'public', + }, + { + id: 'demo-standalone-junho-gyeongridan', + imageUrl: demoImages.cityNight, + lat: 37.53675, + lng: 126.9938, + moodTags: ['신나는'], + note: '경리단길 초입에서 여행 기분을 남겼다.', + placeName: '경리단길 입구', + recordedAt: '2026-07-13T11:35:00.000Z', + templateId: 'lp', + trackId: 'seoul-city', + userProviderId: 'demo-junho', + visibility: 'public', + }, + { + id: 'demo-standalone-doyoon-moscone', + imageUrl: demoImages.cityWalk, + lat: 37.7847, + lng: -122.4058, + moodTags: ['시원한'], + note: '넓은 거리와 잘 어울리는 곡을 골랐다.', + placeName: '모스콘 센터 앞', + recordedAt: '2026-07-13T03:20:00.000Z', + templateId: 'album', + trackId: 'geoje-travel', + userProviderId: 'demo-doyoon', + visibility: 'public', + }, + { + id: 'demo-standalone-seoyeon-maiden-lane', + imageUrl: demoImages.cityNight, + lat: 37.787, + lng: -122.406, + moodTags: ['설레는'], + note: '짧은 골목에서 발견한 오늘의 음악.', + placeName: '메이든 레인', + recordedAt: '2026-07-13T03:32:00.000Z', + templateId: 'film', + trackId: 'geoje-seasons', + userProviderId: 'demo-seoyeon', + visibility: 'public', + }, + { + id: 'demo-standalone-minji-market-street', + imageUrl: demoImages.cityNight, + lat: 37.785, + lng: -122.4073, + moodTags: ['감성적인'], + note: '트램이 지나가는 소리와 음악을 함께 남겼다.', + placeName: '마켓 스트리트', + recordedAt: '2026-07-13T03:45:00.000Z', + templateId: 'map', + trackId: 'hangang', + userProviderId: 'demo-minji', + visibility: 'public', + }, +]; + async function seedTracks() { for (const track of tracks) { await prisma.track.upsert({ @@ -118,6 +642,240 @@ async function seedRegionSoundTrends() { } } +function createDemoTrackSnapshot(track: Track) { + return { + id: track.id, + title: track.title, + artist: track.artist, + ...(track.albumImageUrl ? { albumImageUrl: track.albumImageUrl } : {}), + ...(track.externalUrl ? { externalUrl: track.externalUrl } : {}), + ...(track.fallbackColor ? { fallbackColor: track.fallbackColor } : {}), + ...(track.platformUrls ? { platformUrls: track.platformUrls } : {}), + }; +} + +function createDemoRecapMoment(capture: DemoCaptureSeed, track: Track) { + return { + id: capture.id, + imageUrl: capture.imageUrl, + location: { lat: capture.lat, lng: capture.lng }, + placeName: capture.placeName, + recordedAt: capture.recordedAt, + templateId: capture.templateId, + track: createDemoTrackSnapshot(track), + trackTitle: track.title, + artistName: track.artist, + visibility: capture.visibility, + }; +} + +export async function seedDemoCommunity() { + const demoTrackIds = Array.from( + new Set([ + ...demoTravelLogs.flatMap((log) => log.captures.map((capture) => capture.trackId)), + ...demoStandaloneRecaps.map((capture) => capture.trackId), + ]), + ); + const demoTracks = await prisma.track.findMany({ + where: { id: { in: demoTrackIds } }, + }); + const trackById = new Map(demoTracks.map((track) => [track.id, track])); + + if (trackById.size !== demoTrackIds.length) { + throw new Error('Demo community seed requires the public track catalog first.'); + } + + for (const userSeed of demoCommunityUsers) { + const user = await prisma.user.upsert({ + where: { + provider_providerUserId: { + provider: 'seed', + providerUserId: userSeed.providerUserId, + }, + }, + update: { displayName: userSeed.displayName }, + create: { + displayName: userSeed.displayName, + provider: 'seed', + providerUserId: userSeed.providerUserId, + }, + }); + + await prisma.userProfile.upsert({ + where: { userId: user.id }, + update: { + companionType: userSeed.companionType, + completedOnboarding: true, + dislikedArtists: [], + locationRecommendationEnabled: true, + preferredGenres: userSeed.preferredGenres, + preferredMoods: userSeed.preferredMoods, + travelStyles: userSeed.travelStyles, + }, + create: { + companionType: userSeed.companionType, + completedOnboarding: true, + locationRecommendationEnabled: true, + preferredGenres: userSeed.preferredGenres, + preferredMoods: userSeed.preferredMoods, + travelStyles: userSeed.travelStyles, + userId: user.id, + }, + }); + + await prisma.musicPlatform.upsert({ + where: { userId: user.id }, + update: { + connected: false, + providerUserId: null, + selectedPlatformId: 'none', + }, + create: { + connected: false, + selectedPlatformId: 'none', + userId: user.id, + }, + }); + + await prisma.recapShareEvent.deleteMany({ where: { userId: user.id } }); + await prisma.recap.deleteMany({ where: { userId: user.id } }); + await prisma.momentLog.deleteMany({ where: { userId: user.id } }); + await prisma.travelSession.deleteMany({ where: { userId: user.id } }); + + const userLogs = demoTravelLogs.filter( + (log) => log.userProviderId === userSeed.providerUserId, + ); + + for (const log of userLogs) { + const firstCapture = log.captures[0]; + const representativeCapture = log.captures.at(-1); + + if (!firstCapture || !representativeCapture) { + throw new Error(`Demo travel log ${log.id} requires at least one capture.`); + } + + const routePoints = log.captures.map((capture) => ({ + lat: capture.lat, + lng: capture.lng, + recordedAt: capture.recordedAt, + })); + + await prisma.travelSession.create({ + data: { + endedAt: new Date(representativeCapture.recordedAt), + id: log.sessionId, + routePoints, + startedAt: new Date(firstCapture.recordedAt), + status: 'ended', + travelMode: log.travelMode, + userId: user.id, + }, + }); + + for (const capture of log.captures) { + const track = trackById.get(capture.trackId)!; + + await prisma.momentLog.create({ + data: { + createdAt: new Date(capture.recordedAt), + id: capture.id, + lat: capture.lat, + lng: capture.lng, + moodTags: capture.moodTags, + note: capture.note, + photoUrl: capture.imageUrl, + placeName: capture.placeName, + sessionId: log.sessionId, + source: 'camera', + syncStatus: 'synced', + templateId: capture.templateId, + trackSnapshot: createDemoTrackSnapshot(track), + travelMode: log.travelMode, + userId: user.id, + visibility: capture.visibility, + }, + }); + } + + const representativeTrack = trackById.get(representativeCapture.trackId)!; + + await prisma.recap.create({ + data: { + backgroundImageUrl: firstCapture.imageUrl, + createdAt: new Date(firstCapture.recordedAt), + discImageUrl: representativeCapture.imageUrl, + id: log.id, + lat: representativeCapture.lat, + lng: representativeCapture.lng, + momentCount: log.captures.length, + moments: log.captures.map((capture) => + createDemoRecapMoment(capture, trackById.get(capture.trackId)!), + ), + placeName: log.placeName, + recordedAt: new Date(representativeCapture.recordedAt), + representativeTrackId: representativeTrack.id, + routePoints, + sessionId: log.sessionId, + templateId: log.templateId, + thumbnailMomentId: firstCapture.id, + title: log.title, + travelSessionId: log.sessionId, + userId: user.id, + visibility: 'public', + }, + }); + } + + const userStandaloneRecaps = demoStandaloneRecaps.filter( + (capture) => capture.userProviderId === userSeed.providerUserId, + ); + + for (const capture of userStandaloneRecaps) { + const track = trackById.get(capture.trackId)!; + + await prisma.momentLog.create({ + data: { + createdAt: new Date(capture.recordedAt), + id: capture.id, + lat: capture.lat, + lng: capture.lng, + moodTags: capture.moodTags, + note: capture.note, + photoUrl: capture.imageUrl, + placeName: capture.placeName, + source: 'camera', + syncStatus: 'synced', + templateId: capture.templateId, + trackSnapshot: createDemoTrackSnapshot(track), + userId: user.id, + visibility: capture.visibility, + }, + }); + + await prisma.recap.create({ + data: { + backgroundImageUrl: capture.imageUrl, + createdAt: new Date(capture.recordedAt), + discImageUrl: capture.imageUrl, + id: `demo-recap-${capture.id}`, + lat: capture.lat, + lng: capture.lng, + momentCount: 1, + moments: [createDemoRecapMoment(capture, track)], + placeName: capture.placeName, + recordedAt: new Date(capture.recordedAt), + representativeTrackId: track.id, + templateId: capture.templateId, + thumbnailMomentId: capture.id, + title: `${capture.placeName} 리캡`, + userId: user.id, + visibility: capture.visibility, + }, + }); + } + } +} + export async function seedPublicCatalog() { await seedTracks(); await seedPlaylists(); @@ -194,6 +952,22 @@ export async function seedDatabase() { await seedPublicCatalog(); await seedPlaces(); + const seedRoutePoints = recaps[0]?.routePoints ?? []; + + await prisma.travelSession.create({ + data: { + id: 'seed-session', + userId: user.id, + status: 'ended', + startedAt: new Date(seedRoutePoints[0]?.recordedAt ?? seedMomentLogs[0].createdAt), + endedAt: new Date( + seedRoutePoints.at(-1)?.recordedAt ?? seedMomentLogs.at(-1)!.createdAt, + ), + routePoints: seedRoutePoints, + travelMode: 'walk', + }, + }); + for (const log of seedMomentLogs) { const track = await prisma.track.findUniqueOrThrow({ where: { id: log.trackId }, @@ -215,6 +989,9 @@ export async function seedDatabase() { moodTags: [...log.moodTags], source: 'camera', syncStatus: 'synced', + templateId: log.templateId, + travelMode: log.travelMode, + visibility: log.visibility, trackSnapshot: { id: track.id, title: track.title, @@ -239,13 +1016,16 @@ export async function seedDatabase() { createdAt: new Date(recap.createdAt), momentCount: recap.momentCount, sessionId: recap.sessionId, + travelSessionId: recap.sessionId, backgroundImageUrl: recap.backgroundImageUrl, discImageUrl: recap.discImageUrl, lat: recap.lat, lng: recap.lng, recordedAt: new Date(recap.recordedAt), moments: recap.moments, + routePoints: recap.routePoints, templateId: recap.templateId, + thumbnailMomentId: recap.thumbnailMomentId, visibility: recap.visibility, }, }); @@ -284,6 +1064,8 @@ export async function seedDatabase() { savedAt: new Date(), }, }); + + await seedDemoCommunity(); } export async function disconnectSeedDatabase() { diff --git a/scripts/check-live-e2e.mjs b/scripts/check-live-e2e.mjs new file mode 100644 index 0000000..c87ab19 --- /dev/null +++ b/scripts/check-live-e2e.mjs @@ -0,0 +1,621 @@ +#!/usr/bin/env node + +const apiBaseUrl = ( + process.argv.slice(2).find((argument) => argument !== '--') || + process.env.LIVE_API_BASE_URL || + 'http://127.0.0.1:4000' +).replace(/\/+$/, ''); +const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +const password = 'SoundlogLive!2026'; +const users = []; +const passedSteps = []; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function request(path, options = {}) { + const headers = {}; + let body; + + if (options.token) { + headers.Authorization = `Bearer ${options.token}`; + } + + if (options.form) { + body = options.form; + } else if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(options.body); + } + + const response = await fetch(`${apiBaseUrl}${path}`, { + body, + headers, + method: options.method ?? 'GET', + redirect: 'manual', + signal: AbortSignal.timeout(options.timeoutMs ?? 15_000), + }); + const text = await response.text(); + let payload; + + try { + payload = text ? JSON.parse(text) : undefined; + } catch { + payload = text; + } + + const expectedStatuses = Array.isArray(options.expectedStatus) + ? options.expectedStatus + : [options.expectedStatus ?? 200]; + + if (!expectedStatuses.includes(response.status)) { + throw new Error( + `${options.method ?? 'GET'} ${path} returned HTTP ${response.status}: ${text.slice(0, 300)}`, + ); + } + + return { payload, response }; +} + +async function step(name, callback) { + await callback(); + passedSteps.push(name); + console.log(`PASS ${name}`); +} + +async function registerUser(label) { + const email = `live-${label}-${runId}@soundlog.test`; + const { payload } = await request('/v1/auth/register', { + body: { + displayName: `Live ${label}`, + email, + password, + }, + expectedStatus: 201, + method: 'POST', + }); + const user = { + accessToken: payload?.data?.accessToken, + email, + id: payload?.data?.user?.id, + refreshToken: payload?.data?.refreshToken, + }; + + assert(typeof user.accessToken === 'string', `${label} access token is missing.`); + assert(typeof user.refreshToken === 'string', `${label} refresh token is missing.`); + assert(typeof user.id === 'string', `${label} user id is missing.`); + users.push(user); + return user; +} + +function createPhotoForm(fields, filename) { + const form = new FormData(); + const fakeJpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]); + + Object.entries(fields).forEach(([key, value]) => { + if (value !== undefined) { + form.set(key, String(value)); + } + }); + form.set('photo', new Blob([fakeJpeg], { type: 'image/jpeg' }), filename); + return form; +} + +async function cleanupUsers() { + for (const user of users.reverse()) { + if (!user.accessToken) { + continue; + } + + try { + await request('/v1/me', { + expectedStatus: [200, 401], + method: 'DELETE', + token: user.accessToken, + }); + } catch (error) { + console.error(`WARN cleanup ${user.email}: ${error instanceof Error ? error.message : error}`); + } + } +} + +let primary; +let companion; + +try { + await step('system health, OpenAPI, docs, and DB write', async () => { + const health = await request('/v1/health'); + assert(health.payload?.data?.status === 'ok', 'Health status is not ok.'); + assert(health.payload?.data?.database === 'ok', 'Database status is not ok.'); + + const openApi = await request('/openapi.yaml'); + assert(String(openApi.payload).includes('openapi: 3.1.0'), 'OpenAPI document is missing.'); + await request('/docs/', { expectedStatus: 200 }); + + const dbRecord = await request('/v1/dev/db-test-records', { + body: { label: `live-e2e-${runId}`, payload: { source: 'check-live-e2e' } }, + expectedStatus: 201, + method: 'POST', + }); + assert(dbRecord.payload?.data?.id, 'DB test write did not return an id.'); + }); + + await step('register, login, refresh, profile, and migration', async () => { + primary = await registerUser('primary'); + companion = await registerUser('companion'); + + const login = await request('/v1/auth/login', { + body: { email: primary.email, password }, + method: 'POST', + }); + assert(login.payload?.data?.user?.id === primary.id, 'Login returned another user.'); + + const refresh = await request('/v1/auth/refresh', { + body: { refreshToken: primary.refreshToken }, + method: 'POST', + }); + primary.accessToken = refresh.payload?.data?.accessToken; + primary.refreshToken = refresh.payload?.data?.refreshToken; + assert(typeof primary.accessToken === 'string', 'Refresh did not rotate the access token.'); + + await request('/v1/me/profile', { + body: { + companionType: 'friends', + dislikedArtists: [], + locationRecommendationEnabled: true, + preferredGenres: ['K-POP', '인디'], + preferredMoods: ['잔잔한', '시원한'], + travelStyles: ['산책', '바다'], + }, + method: 'PUT', + token: primary.accessToken, + }); + const me = await request('/v1/me', { token: primary.accessToken }); + assert(me.payload?.data?.profile?.completedOnboarding === true, 'Profile was not completed.'); + + await request('/v1/me/migrate-local-data', { + body: { + idempotencyKey: `live-migration-${runId}`, + libraryTrackCount: 1, + momentLogCount: 2, + recapDraftCount: 1, + }, + method: 'POST', + token: primary.accessToken, + }); + }); + + await step('tour, home, playlist, recommendation, trend, and library', async () => { + const search = await request('/v1/tour/places?query=%EA%B4%91%EC%95%88%EB%A6%AC&limit=5', { + token: primary.accessToken, + }); + assert(Array.isArray(search.payload?.data), 'Tour search did not return an array.'); + + const nearby = await request( + '/v1/tour/nearby-places?lat=35.1532&lng=129.1186&radiusMeters=2000&limit=5', + { token: primary.accessToken }, + ); + assert(Array.isArray(nearby.payload?.data), 'Nearby places did not return an array.'); + await request('/v1/tour/reverse-geocode?lat=37.5512&lng=126.9882', { + token: primary.accessToken, + }); + + const featured = await request( + '/v1/home/featured-playlists?locationRecommendationEnabled=true&recommendationMode=everyday&lat=35.1532&lng=129.1186', + { token: primary.accessToken }, + ); + assert(Array.isArray(featured.payload?.data), 'Featured playlists did not return an array.'); + + const moods = await request( + '/v1/home/mood-recommendations?moodFilter=%EC%A0%84%EC%B2%B4&limit=4', + { token: primary.accessToken }, + ); + assert(Array.isArray(moods.payload?.data), 'Mood recommendations did not return an array.'); + + const contextual = await request('/v1/playlists/contextual', { + body: { + location: { lat: 35.1532, lng: 129.1186 }, + mood: '시원한', + moodTags: ['fresh'], + state: '바다', + travelMode: 'ocean', + }, + expectedStatus: 201, + method: 'POST', + timeoutMs: 20_000, + token: primary.accessToken, + }); + assert(contextual.payload?.data?.tracks?.length > 0, 'Contextual playlist has no tracks.'); + + const recommended = await request( + '/v1/recommendations/playlists?mood=%EC%8B%9C%EC%9B%90%ED%95%9C&state=%EB%B0%94%EB%8B%A4&x=129.1186&y=35.1532', + { timeoutMs: 20_000, token: primary.accessToken }, + ); + assert(recommended.payload?.data?.tracks?.length > 0, 'Recommended playlist has no tracks.'); + + const playlist = await request('/v1/playlists/busan-ocean', { + token: primary.accessToken, + }); + assert(playlist.payload?.data?.tracks?.length > 0, 'Seed playlist has no tracks.'); + + await request('/v1/library/tracks/seoul-city', { + body: { action: 'like', playlistId: 'seoul-night' }, + method: 'PUT', + token: primary.accessToken, + }); + const library = await request('/v1/library/tracks?kind=liked', { + token: primary.accessToken, + }); + assert( + library.payload?.data?.some((track) => track.id === 'seoul-city'), + 'Liked track is missing from the library.', + ); + + const trend = await request('/v1/trends/regions/KR-26/sound?period=weekly', { + token: primary.accessToken, + }); + assert(trend.payload?.data?.regionCode === 'KR-26', 'Regional trend is incorrect.'); + }); + + let primarySessionId; + let companionSessionId; + let firstCaptureId; + let secondCaptureId; + let recapId; + + await step('travel session, recap capture, log, marker, and sharing', async () => { + const startedAt = new Date().toISOString(); + const primarySession = await request('/v1/travel-sessions', { + body: { + location: { lat: 37.5512, lng: 126.9882 }, + startedAt, + travelMode: 'walk', + }, + expectedStatus: 201, + method: 'POST', + token: primary.accessToken, + }); + primarySessionId = primarySession.payload?.data?.id; + assert(primarySessionId, 'Primary travel session was not created.'); + + const firstCapture = await request('/v1/recap-captures', { + expectedStatus: 201, + form: createPhotoForm( + { + createdAt: new Date().toISOString(), + lat: 37.5512, + lng: 126.9882, + moodTags: 'calm,fresh', + note: '남산에서 만든 라이브 E2E 리캡', + placeName: '남산서울타워', + sessionId: primarySessionId, + templateId: 'map', + trackId: 'seoul-city', + travelMode: 'walk', + visibility: 'public', + }, + `live-first-${runId}.jpg`, + ), + method: 'POST', + token: primary.accessToken, + }); + firstCaptureId = firstCapture.payload?.data?.id; + + const secondCapture = await request('/v1/moment-logs', { + expectedStatus: 201, + form: createPhotoForm( + { + createdAt: new Date(Date.now() + 1000).toISOString(), + lat: 37.552, + lng: 126.989, + moodTags: 'emotional', + note: '두 번째 실제 DB 리캡', + placeName: '남산 산책로', + sessionId: primarySessionId, + templateId: 'film', + trackId: 'night-letter', + travelMode: 'walk', + visibility: 'public', + }, + `live-second-${runId}.jpg`, + ), + method: 'POST', + token: primary.accessToken, + }); + secondCaptureId = secondCapture.payload?.data?.id; + assert(firstCaptureId && secondCaptureId, 'Recap captures were not created.'); + + await request(`/v1/recap-captures/${firstCaptureId}`, { + body: { note: '수정된 라이브 리캡', templateId: 'album' }, + method: 'PATCH', + token: primary.accessToken, + }); + await request(`/v1/recap-captures/${firstCaptureId}/photo`, { + form: createPhotoForm({}, `live-replaced-${runId}.jpg`), + method: 'PUT', + token: primary.accessToken, + }); + + const captures = await request(`/v1/recap-captures?sessionId=${primarySessionId}`, { + token: primary.accessToken, + }); + assert(captures.payload?.data?.length === 2, 'Session recap capture count is not 2.'); + + await request('/v1/recommendation-events', { + body: { + events: [ + { + context: { placeName: '남산서울타워', recommendationMode: 'everyday' }, + createdAt: new Date().toISOString(), + id: `live-event-${runId}`, + sessionId: primarySessionId, + trackId: 'seoul-city', + type: 'moment_log_saved', + }, + ], + }, + expectedStatus: 202, + method: 'POST', + token: primary.accessToken, + }); + + const recap = await request('/v1/recaps', { + body: { + momentLogIds: [firstCaptureId, secondCaptureId], + sessionId: primarySessionId, + templateId: 'map', + title: '라이브 남산 사운드 로그', + visibility: 'public', + }, + expectedStatus: 201, + method: 'POST', + token: primary.accessToken, + }); + recapId = recap.payload?.data?.id; + assert(recapId, 'Travel log was not created.'); + + const mine = await request('/v1/recaps?scope=mine', { token: primary.accessToken }); + assert(mine.payload?.data?.some((item) => item.id === recapId), 'Created log is missing.'); + const share = await request(`/v1/recaps/${recapId}/share`, { + token: primary.accessToken, + }); + assert(share.payload?.data?.moments?.length === 2, 'Log detail does not contain two recaps.'); + assert(share.payload?.data?.routePoints !== undefined, 'Owner route points are missing.'); + + const markers = await request( + '/v1/recap-markers?scope=public&lat=37.552&lng=126.989&radiusMeters=300', + { token: companion.accessToken }, + ); + assert( + markers.payload?.data?.some((marker) => marker.recapId === recapId), + 'Public recap marker is missing for another user.', + ); + + await request(`/v1/recaps/${recapId}/share-events`, { + body: { createdAt: new Date().toISOString(), type: 'save_image' }, + expectedStatus: 202, + method: 'POST', + token: primary.accessToken, + }); + await request(`/v1/recaps/${recapId}/visibility`, { + body: { visibility: 'private' }, + method: 'PATCH', + token: primary.accessToken, + }); + await request(`/v1/recaps/${recapId}/visibility`, { + body: { visibility: 'public' }, + method: 'PATCH', + token: primary.accessToken, + }); + }); + + await step('travel room, collaborative recap, sound map, matching, and safety', async () => { + const companionSession = await request('/v1/travel-sessions', { + body: { + location: { lat: 37.5522, lng: 126.9892 }, + travelMode: 'walk', + }, + expectedStatus: 201, + method: 'POST', + token: companion.accessToken, + }); + companionSessionId = companionSession.payload?.data?.id; + + const room = await request('/v1/travel-rooms', { + body: { sessionId: primarySessionId, title: '라이브 남산 여행방' }, + expectedStatus: 201, + method: 'POST', + token: primary.accessToken, + }); + const roomId = room.payload?.data?.id; + const inviteCode = room.payload?.data?.inviteCode; + assert(roomId && inviteCode, 'Travel room id or invite code is missing.'); + + const joined = await request('/v1/travel-rooms/join', { + body: { displayName: 'Live Companion', inviteCode: inviteCode.toLowerCase() }, + method: 'POST', + token: companion.accessToken, + }); + assert(joined.payload?.data?.memberCount === 2, 'Companion did not join the room.'); + await request(`/v1/travel-rooms/${roomId}`, { token: primary.accessToken }); + const rooms = await request(`/v1/travel-rooms?sessionId=${primarySessionId}`, { + token: primary.accessToken, + }); + assert(rooms.payload?.data?.some((item) => item.id === roomId), 'Room list is missing the room.'); + + const roomMoment = await request(`/v1/travel-rooms/${roomId}/moments`, { + body: { + note: '동행자가 고른 남산 리캡', + placeName: '남산 산책로', + status: 'candidate', + trackId: 'night-letter', + }, + expectedStatus: 201, + method: 'POST', + token: companion.accessToken, + }); + const roomMomentId = roomMoment.payload?.data?.id; + await request(`/v1/travel-rooms/${roomId}/moments/${roomMomentId}`, { + body: { status: 'accepted' }, + method: 'PATCH', + token: primary.accessToken, + }); + await request(`/v1/travel-rooms/${roomId}/moments/${roomMomentId}/comments`, { + body: { body: '이 리캡을 공동 로그 대표로 사용해요.' }, + expectedStatus: 201, + method: 'POST', + token: primary.accessToken, + }); + const collaborativeRecap = await request(`/v1/travel-rooms/${roomId}/recaps`, { + body: { templateId: 'album', title: '라이브 공동 로그' }, + expectedStatus: 201, + method: 'POST', + token: primary.accessToken, + }); + assert(collaborativeRecap.payload?.data?.roomId === roomId, 'Collaborative recap is invalid.'); + + const companionPin = await request('/v1/sound-map/current-track', { + body: { + location: { lat: 37.5522, lng: 126.9892 }, + moodTags: ['calm'], + placeName: '남산 산책로', + sessionId: companionSessionId, + trackId: 'night-letter', + travelMode: 'walk', + visibility: 'nearby', + }, + expectedStatus: 202, + method: 'POST', + token: companion.accessToken, + }); + const companionPinId = companionPin.payload?.data?.id; + + await request('/v1/sound-map/current-track', { + body: { + location: { lat: 37.5512, lng: 126.9882 }, + moodTags: ['fresh'], + placeName: '남산서울타워', + sessionId: primarySessionId, + trackId: 'seoul-city', + travelMode: 'walk', + visibility: 'companions', + }, + expectedStatus: 202, + method: 'POST', + token: primary.accessToken, + }); + + const soundMap = await request( + '/v1/sound-map?lat=37.5512&lng=126.9882&radiusMeters=3000', + { token: primary.accessToken }, + ); + assert(soundMap.payload?.data?.length >= 2, 'Sound map does not contain both users.'); + + const nearby = await request( + '/v1/sound-map/nearby?lat=37.5512&lng=126.9882&mood=%EC%9E%94%EC%9E%94%ED%95%9C&state=%EC%82%B0%EC%B1%85', + { token: primary.accessToken }, + ); + assert( + nearby.payload?.data?.some((item) => item.targetPinId === companionPinId), + 'Nearby sound did not return the companion.', + ); + const matches = await request( + '/v1/music-matches?lat=37.5512&lng=126.9882&mood=%EC%9E%94%EC%9E%94%ED%95%9C&state=%EC%82%B0%EC%B1%85', + { token: primary.accessToken }, + ); + assert( + matches.payload?.data?.some((item) => item.targetPinId === companionPinId), + 'Music match did not return the companion.', + ); + + const mateRequest = await request('/v1/travel-mate-requests', { + body: { messageTemplate: 'walk_together', targetPinId: companionPinId }, + expectedStatus: 201, + method: 'POST', + token: primary.accessToken, + }); + const mateRequestId = mateRequest.payload?.data?.id; + const inbox = await request('/v1/travel-mate-requests?box=inbox&status=pending', { + token: companion.accessToken, + }); + assert(inbox.payload?.data?.some((item) => item.id === mateRequestId), 'Mate request is not in inbox.'); + await request(`/v1/travel-mate-requests/${mateRequestId}`, { + body: { action: 'accept' }, + method: 'PATCH', + token: companion.accessToken, + }); + + await request('/v1/community/reports', { + body: { + details: '라이브 E2E 안전 신고 검증', + reason: 'other', + targetPinId: companionPinId, + targetUserId: companion.id, + }, + expectedStatus: 202, + method: 'POST', + token: primary.accessToken, + }); + await request('/v1/community/blocks', { + body: { targetPinId: companionPinId }, + expectedStatus: 202, + method: 'POST', + token: primary.accessToken, + }); + const hiddenMatches = await request( + '/v1/music-matches?lat=37.5512&lng=126.9882&mood=%EC%9E%94%EC%9E%94%ED%95%9C&state=%EC%82%B0%EC%B1%85', + { token: primary.accessToken }, + ); + assert( + !hiddenMatches.payload?.data?.some((item) => item.targetPinId === companionPinId), + 'Blocked user is still visible in matches.', + ); + }); + + await step('travel completion, recent logs, photo deletion, and logout', async () => { + await request(`/v1/travel-sessions/${primarySessionId}`, { + body: { + endedAt: new Date().toISOString(), + routePoints: [ + { lat: 37.5512, lng: 126.9882, recordedAt: new Date().toISOString() }, + { lat: 37.552, lng: 126.989, recordedAt: new Date(Date.now() + 1000).toISOString() }, + ], + status: 'ended', + }, + method: 'PATCH', + token: primary.accessToken, + }); + await request(`/v1/travel-sessions/${companionSessionId}`, { + body: { endedAt: new Date().toISOString(), status: 'ended' }, + method: 'PATCH', + token: companion.accessToken, + }); + + const recent = await request('/v1/home/recent-music-logs?limit=5', { + token: primary.accessToken, + }); + assert(Array.isArray(recent.payload?.data), 'Recent music logs did not return an array.'); + + await request(`/v1/recap-captures/${firstCaptureId}/photo`, { + expectedStatus: 200, + method: 'DELETE', + token: primary.accessToken, + }); + await request('/v1/auth/logout', { + body: { refreshToken: primary.refreshToken }, + expectedStatus: 202, + method: 'POST', + }); + }); + + console.log(`Live API E2E passed (${passedSteps.length} feature groups) against ${apiBaseUrl}.`); +} catch (error) { + console.error(`Live API E2E failed after ${passedSteps.length} passed groups.`); + console.error(error instanceof Error ? error.stack ?? error.message : error); + process.exitCode = 1; +} finally { + await cleanupUsers(); +} diff --git a/scripts/check-openapi-route-sync.mjs b/scripts/check-openapi-route-sync.mjs new file mode 100644 index 0000000..29b971d --- /dev/null +++ b/scripts/check-openapi-route-sync.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(scriptDir, '..'); +const routeSource = fs.readFileSync( + path.join(projectRoot, 'src/routes/index.ts'), + 'utf8', +); +const openApiSource = fs.readFileSync( + path.join(projectRoot, 'openapi/soundlog-api.yaml'), + 'utf8', +); +const supportedMethods = 'get|post|put|patch|delete'; + +function normalizeExpressPath(routePath) { + return routePath.replace(/:([A-Za-z0-9_]+)/g, '{$1}'); +} + +function getExpressOperations() { + const routePattern = new RegExp( + `router\\.(${supportedMethods})\\(\\s*['\"]([^'\"]+)`, + 'g', + ); + + return Array.from(routeSource.matchAll(routePattern), (match) => + `${match[1].toUpperCase()} ${normalizeExpressPath(match[2])}`, + ); +} + +function getOpenApiOperations() { + const operations = []; + let currentPath; + + openApiSource.split(/\r?\n/).forEach((line) => { + const pathMatch = line.match(/^ (\/[^:]+):\s*$/); + + if (pathMatch) { + currentPath = pathMatch[1]; + return; + } + + const methodMatch = line.match( + new RegExp(`^ (${supportedMethods}):\\s*$`), + ); + + if (methodMatch && currentPath) { + operations.push(`${methodMatch[1].toUpperCase()} ${currentPath}`); + } + }); + + return operations; +} + +function findDuplicates(operations) { + const seen = new Set(); + const duplicates = new Set(); + + operations.forEach((operation) => { + if (seen.has(operation)) { + duplicates.add(operation); + } + + seen.add(operation); + }); + + return Array.from(duplicates).sort(); +} + +const expressOperations = getExpressOperations(); +const openApiOperations = getOpenApiOperations(); +const expressOperationSet = new Set(expressOperations); +const openApiOperationSet = new Set(openApiOperations); +const missingFromOpenApi = expressOperations + .filter((operation) => !openApiOperationSet.has(operation)) + .sort(); +const missingFromExpress = openApiOperations + .filter((operation) => !expressOperationSet.has(operation)) + .sort(); +const duplicateExpressOperations = findDuplicates(expressOperations); +const duplicateOpenApiOperations = findDuplicates(openApiOperations); + +if ( + missingFromOpenApi.length > 0 || + missingFromExpress.length > 0 || + duplicateExpressOperations.length > 0 || + duplicateOpenApiOperations.length > 0 +) { + console.error('Express/OpenAPI route sync check failed.'); + + if (missingFromOpenApi.length > 0) { + console.error('\nMissing from OpenAPI:'); + missingFromOpenApi.forEach((operation) => console.error(`- ${operation}`)); + } + + if (missingFromExpress.length > 0) { + console.error('\nDocumented but not implemented:'); + missingFromExpress.forEach((operation) => console.error(`- ${operation}`)); + } + + if (duplicateExpressOperations.length > 0) { + console.error('\nDuplicate Express operations:'); + duplicateExpressOperations.forEach((operation) => + console.error(`- ${operation}`), + ); + } + + if (duplicateOpenApiOperations.length > 0) { + console.error('\nDuplicate OpenAPI operations:'); + duplicateOpenApiOperations.forEach((operation) => + console.error(`- ${operation}`), + ); + } + + process.exit(1); +} + +console.log( + `Express/OpenAPI route sync check passed (${expressOperations.length} operations).`, +); diff --git a/scripts/check-public-api-contract.mjs b/scripts/check-public-api-contract.mjs index 5aafb02..800ca7f 100644 --- a/scripts/check-public-api-contract.mjs +++ b/scripts/check-public-api-contract.mjs @@ -3,6 +3,8 @@ const cliApiBaseUrl = process.argv.slice(2).find((arg) => arg !== '--'); const apiBaseUrl = (cliApiBaseUrl || process.env.PUBLIC_API_BASE_URL || '').replace(/\/+$/, ''); const errors = []; +let accessToken = process.env.PUBLIC_API_ACCESS_TOKEN; +let ownsContractUser = false; if (!apiBaseUrl) { console.error( @@ -19,8 +21,25 @@ function withBase(path) { return `${apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`; } -async function fetchText(path) { +async function fetchText(path, options = {}) { + const headers = {}; + + if (options.authenticated) { + if (!accessToken) { + throw new Error(`${path} requires an authenticated contract session.`); + } + + headers.Authorization = `Bearer ${accessToken}`; + } + + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + const response = await fetch(withBase(path), { + body: options.body === undefined ? undefined : JSON.stringify(options.body), + headers, + method: options.method ?? 'GET', redirect: 'manual', signal: AbortSignal.timeout(10_000), }); @@ -29,8 +48,8 @@ async function fetchText(path) { return { response, text }; } -async function fetchJson(path) { - const { response, text } = await fetchText(path); +async function fetchJson(path, options) { + const { response, text } = await fetchText(path, options); if (!response.ok) { throw new Error(`${path} returned HTTP ${response.status}: ${text.slice(0, 160)}`); @@ -39,6 +58,56 @@ async function fetchJson(path) { return JSON.parse(text); } +async function createContractSession() { + if (accessToken) { + return; + } + + try { + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const payload = await fetchJson('/v1/auth/register', { + body: { + displayName: 'Soundlog Contract Check', + email: `soundlog-contract-${uniqueId}@example.com`, + password: 'SoundlogContract!2026', + }, + method: 'POST', + }); + + if (typeof payload?.data?.accessToken !== 'string') { + addError('/v1/auth/register did not return an access token for contract checks.'); + return; + } + + accessToken = payload.data.accessToken; + ownsContractUser = true; + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } +} + +async function deleteContractUser() { + if (!ownsContractUser || !accessToken) { + return; + } + + try { + const { response, text } = await fetchText('/v1/me', { + authenticated: true, + method: 'DELETE', + }); + + if (!response.ok) { + addError(`/v1/me cleanup returned HTTP ${response.status}: ${text.slice(0, 160)}`); + } + } catch (error) { + addError(`Contract user cleanup failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + accessToken = undefined; + ownsContractUser = false; + } +} + async function verifyHealth() { try { const payload = await fetchJson('/v1/health'); @@ -71,6 +140,7 @@ async function verifyNearbyPlaces() { try { const payload = await fetchJson( '/v1/tour/nearby-places?lat=35.1595&lng=129.1604&radiusMeters=2000&limit=1', + { authenticated: true }, ); if (!Array.isArray(payload?.data)) { @@ -99,7 +169,8 @@ async function verifyNearbyPlaces() { async function verifyMusicMetadata() { try { const payload = await fetchJson( - '/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', + '/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday', + { authenticated: true }, ); const serialized = JSON.stringify(payload?.data ?? {}); @@ -117,7 +188,9 @@ async function verifyMusicMetadata() { async function verifyPlaylistCatalog() { try { - const payload = await fetchJson('/v1/playlists/geoje-ocean'); + const payload = await fetchJson('/v1/playlists/geoje-ocean', { + authenticated: true, + }); const playlist = payload?.data; if (playlist?.id !== 'geoje-ocean' || !Array.isArray(playlist.tracks) || playlist.tracks.length === 0) { @@ -130,7 +203,9 @@ async function verifyPlaylistCatalog() { async function verifyRemovedMusicPlatformRoute() { try { - const { response, text } = await fetchText('/v1/me/music-platform'); + const { response, text } = await fetchText('/v1/me/music-platform', { + authenticated: true, + }); if (response.status !== 404) { addError( @@ -147,10 +222,16 @@ async function verifyRemovedMusicPlatformRoute() { await verifyHealth(); await verifyOpenApi(); -await verifyNearbyPlaces(); -await verifyMusicMetadata(); -await verifyPlaylistCatalog(); -await verifyRemovedMusicPlatformRoute(); +await createContractSession(); + +try { + await verifyNearbyPlaces(); + await verifyMusicMetadata(); + await verifyPlaylistCatalog(); + await verifyRemovedMusicPlatformRoute(); +} finally { + await deleteContractUser(); +} if (errors.length > 0) { console.error('Public API contract check failed:'); diff --git a/src/config/env.ts b/src/config/env.ts index 828c4ba..8026293 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -21,6 +21,14 @@ const envSchema = z.object({ NODE_ENV: z.string().default('development'), PORT: z.coerce.number().int().positive().default(4000), REQUEST_BODY_LIMIT: z.string().min(1).default('1mb'), + REVERSE_GEOCODING_BASE_URL: z + .string() + .url() + .default('https://nominatim.openstreetmap.org'), + REVERSE_GEOCODING_USER_AGENT: z + .string() + .min(8) + .default('Soundlog/0.1 (+https://github.com/SoundLogTeam/SoundLogServer)'), TOUR_API_BASE_URL: z.string().url().default('https://apis.data.go.kr/B551011/KorService2'), TOUR_API_SERVICE_KEY: z.string().optional(), USE_MOCK_DB: z diff --git a/src/constants/error.constants.ts b/src/constants/error.constants.ts index d92ddea..1e844ce 100644 --- a/src/constants/error.constants.ts +++ b/src/constants/error.constants.ts @@ -24,6 +24,11 @@ export const ERROR_MESSAGES = { MOMENT_LOG_NOT_FOUND: 'Moment Log를 찾을 수 없습니다.', USER_ALREADY_EXISTS: '이미 가입된 이메일입니다.', RECAP_NOT_FOUND: '리캡을 찾을 수 없습니다.', + RECAP_LOG_REQUIRES_CAPTURE: '여행 로그에는 하나 이상의 리캡이 필요합니다.', + RECAP_LOG_CAPTURE_MISMATCH: '요청한 리캡이 여행 세션과 일치하지 않습니다.', + RECAP_LOG_PUBLIC_CAPTURE_REQUIRED: '전체공개 로그에는 공개 위치가 있는 리캡이 하나 이상 필요합니다.', + RECAP_THUMBNAIL_LOG_REQUIRED: '여행 로그만 썸네일을 지정할 수 있습니다.', + RECAP_THUMBNAIL_MOMENT_NOT_FOUND: '로그에 포함된 리캡만 썸네일로 지정할 수 있습니다.', RECAP_PUBLIC_LOCATION_REQUIRED: '전체공개 리캡은 대표 위치가 필요합니다.', RECOMMENDATION_TRACK_NOT_FOUND: '추천 트랙을 찾을 수 없습니다.', REGION_SOUND_TREND_NOT_FOUND: '지역 사운드 트렌드를 찾을 수 없습니다.', diff --git a/src/controllers/me.controller.ts b/src/controllers/me.controller.ts index e1752d1..69114bb 100644 --- a/src/controllers/me.controller.ts +++ b/src/controllers/me.controller.ts @@ -16,6 +16,11 @@ export const meController = { res.json(dataResponse(await apiService.getMyProfile(user.id))); }, + async deleteAccount(req: Request, res: Response) { + const user = requireUser(req); + res.json(dataResponse(await apiService.deleteMyAccount(user.id))); + }, + async upsertProfile(req: Request, res: Response) { const user = requireUser(req); res.json(dataResponse(await apiService.upsertMyProfile(user.id, req.body))); diff --git a/src/controllers/recap.controller.ts b/src/controllers/recap.controller.ts index 163d09c..aeea076 100644 --- a/src/controllers/recap.controller.ts +++ b/src/controllers/recap.controller.ts @@ -46,6 +46,19 @@ export const recapController = { ); }, + async updateRecapThumbnail(req: Request, res: Response) { + const user = requireUser(req); + res.json( + dataResponse( + await apiService.updateRecapThumbnail( + user.id, + String(req.params.recapId), + req.body, + ), + ), + ); + }, + async createShareEvent(req: Request, res: Response) { const user = requireUser(req); await apiService.createRecapShareEvent( diff --git a/src/controllers/tour.controller.ts b/src/controllers/tour.controller.ts index 16799bd..9db61a9 100644 --- a/src/controllers/tour.controller.ts +++ b/src/controllers/tour.controller.ts @@ -4,7 +4,15 @@ import { apiService } from '../services/api.service.js'; import { dataResponse } from '../utils/response.js'; export const tourController = { + async searchPlaces(req: Request, res: Response) { + res.json(dataResponse(await apiService.searchPlaces(req.query as never))); + }, + async getNearbyPlaces(req: Request, res: Response) { res.json(dataResponse(await apiService.getNearbyPlaces(req.query as never))); }, + + async reverseGeocodeLocation(req: Request, res: Response) { + res.json(dataResponse(await apiService.reverseGeocodeLocation(req.query as never))); + }, }; diff --git a/src/data/seed-data.ts b/src/data/seed-data.ts index 40773a3..2a12a1f 100644 --- a/src/data/seed-data.ts +++ b/src/data/seed-data.ts @@ -68,7 +68,7 @@ export const places = [ category: '야경 명소', contentType: '관광지', distanceMeters: 620, - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', lat: 37.5512, lng: 126.9882, overview: '서울의 야경과 도심 산책을 함께 즐길 수 있는 대표 관광지입니다.', @@ -81,7 +81,7 @@ export const places = [ category: '문화시설', contentType: '관광지', distanceMeters: 940, - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', lat: 37.5759, lng: 126.9768, overview: '도시 산책과 역사 관광 맥락을 함께 제공하는 서울 중심 관광지입니다.', @@ -156,20 +156,97 @@ export const playlists = [ durationText: '36:00분', placeName: '서울 야경', reason: '현재 위치를 바탕으로 추천했어요', - coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', source: 'trend', trackIds: ['seoul-city', 'moon-seoul', 'hangang', 'night-letter', 'hongdae-kondae', 'seoul-night-track'], }, + { + id: 'calm-walk', + regionName: '잔잔한 산책', + description: '천천히 걷는 시간에 어울리는 차분한 음악을 모았어요.', + durationText: '24:00분', + placeName: '도심 산책길', + reason: '조용한 산책과 감성적인 풍경에 어울리는 음악이에요', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + source: 'personalized', + trackIds: [ + 'seoul-city', + 'night-letter', + 'hangang', + 'geoje-seasons', + 'geoje-tree', + 'geoje-everything', + ], + }, + { + id: 'drive', + regionName: '드라이브 팝', + description: '창문을 열고 달릴 때 어울리는 시원한 음악을 모았어요.', + durationText: '24:00분', + placeName: '해안 드라이브', + reason: '신나는 드라이브와 시원한 풍경에 어울리는 음악이에요', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + source: 'personalized', + trackIds: [ + 'moon-seoul', + 'geoje-travel', + 'geoje-dinosaur-ridge', + 'geoje-summer', + 'hongdae-kondae', + 'seoul-night-track', + ], + }, + { + id: 'city-night', + regionName: '도시의 야경', + description: '도시 불빛을 바라보며 듣기 좋은 음악을 모았어요.', + durationText: '20:00분', + placeName: '서울 야경', + reason: '설레고 감성적인 밤 풍경에 어울리는 음악이에요', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + source: 'personalized', + trackIds: [ + 'hangang', + 'seoul-city', + 'night-letter', + 'seoul-night-track', + 'geoje-everything', + ], + }, + { + id: 'cafe-indie', + regionName: '카페 인디', + description: '카페에 머무는 시간과 어울리는 인디 음악을 모았어요.', + durationText: '20:00분', + placeName: '동네 카페', + reason: '잔잔하고 시원한 카페 분위기에 어울리는 음악이에요', + coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + source: 'personalized', + trackIds: [ + 'seoul-night-track', + 'night-letter', + 'geoje-wi-ing', + 'geoje-seasons', + 'hangang', + ], + }, ] as const; export const moodRecommendations = [ { id: 'calm-walk', title: '잔잔한\n산책', + subtitle: '천천히 걷는 시간', color: '#2B176C', genres: ['인디', '발라드'], + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', moods: ['잔잔한', '감성적인'], + playlistId: 'calm-walk', travelStyles: ['산책', '야경 감상'], trackId: 'seoul-city', sortOrder: 1, @@ -177,9 +254,12 @@ export const moodRecommendations = [ { id: 'drive', title: '드라이브\n팝', + subtitle: '창문을 열고 달리는 순간', color: '#B1913A', genres: ['팝', 'K-POP'], - moods: ['신나는', '청량한', '활기찬'], + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + moods: ['신나는', '시원한', '활기찬'], + playlistId: 'drive', travelStyles: ['드라이브', '바다 보기'], trackId: 'moon-seoul', sortOrder: 2, @@ -187,9 +267,12 @@ export const moodRecommendations = [ { id: 'city-night', title: '도시의\n야경', + subtitle: '불빛이 번지는 밤', color: '#1F2937', genres: ['R&B', 'OST'], - moods: ['감성적인', '잔잔한'], + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + moods: ['감성적인', '잔잔한', '설레는'], + playlistId: 'city-night', travelStyles: ['야경 감상', '카페 투어'], trackId: 'hangang', sortOrder: 3, @@ -197,9 +280,12 @@ export const moodRecommendations = [ { id: 'cafe-indie', title: '카페\n인디', + subtitle: '잠시 머물고 싶은 오후', color: '#3F2C6B', genres: ['인디', 'R&B'], - moods: ['잔잔한', '청량한'], + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + moods: ['잔잔한', '시원한'], + playlistId: 'cafe-indie', travelStyles: ['카페 투어', '산책'], trackId: 'seoul-night-track', sortOrder: 4, @@ -209,7 +295,7 @@ export const moodRecommendations = [ export const seedMomentLogs = [ { id: 'log-1', - photoUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + photoUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', createdAt: '2026-05-25T00:00:00.000Z', lat: 35.1532, lng: 129.1187, @@ -217,30 +303,39 @@ export const seedMomentLogs = [ note: '바다 앞에서 처음 저장한 사운드', trackId: 'seoul-city', sessionId: 'seed-session', + templateId: 'album', + travelMode: 'walk', + visibility: 'public', moodTags: ['fresh'], }, { id: 'log-2', photoUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', createdAt: '2026-05-25T00:10:00.000Z', - lat: 37.5294, - lng: 126.9348, - placeName: '한강', - note: '강변 산책에 어울린 잔잔한 곡', + lat: 35.1587, + lng: 129.1604, + placeName: '해운대', + note: '해변 산책에 어울린 잔잔한 곡', trackId: 'night-letter', sessionId: 'seed-session', + templateId: 'film', + travelMode: 'walk', + visibility: 'public', moodTags: ['calm'], }, { id: 'log-3', photoUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', createdAt: '2026-05-25T00:20:00.000Z', - lat: 35.1796, - lng: 129.0756, - placeName: '부산', + lat: 35.157, + lng: 129.063, + placeName: '전포', note: '여행 에너지가 올라간 순간', trackId: 'seoul-night-track', sessionId: 'seed-session', + templateId: 'lp', + travelMode: 'walk', + visibility: 'public', moodTags: ['active'], }, ] as const; @@ -248,55 +343,58 @@ export const seedMomentLogs = [ export const recaps = [ { id: 'seoul-night', - title: '서울의 밤', - placeName: 'Seoul', - representativeTrackId: 'seoul-city', + title: '부산 사운드 로그', + placeName: '부산', + representativeTrackId: 'seoul-night-track', createdAt: '2026-05-25T00:00:00.000Z', momentCount: 3, sessionId: 'seed-session', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', - discImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', - lat: 35.1532, - lng: 129.1187, - recordedAt: '2024-04-24T18:20:00.000+09:00', - templateId: 'lp', + thumbnailMomentId: 'log-1', + backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', + discImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + lat: 35.157, + lng: 129.063, + recordedAt: '2026-05-25T00:20:00.000Z', + routePoints: [ + { lat: 35.1532, lng: 129.1187, recordedAt: '2026-05-25T00:00:00.000Z' }, + { lat: 35.1587, lng: 129.1604, recordedAt: '2026-05-25T00:10:00.000Z' }, + { lat: 35.157, lng: 129.063, recordedAt: '2026-05-25T00:20:00.000Z' }, + ], + templateId: 'album', visibility: 'public', moments: [ { id: 'log-1', - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', location: { lat: 35.1532, lng: 129.1187 }, placeName: '광안리', recordedAt: '2026-05-25T00:00:00.000Z', + templateId: 'album', trackTitle: 'Seoul City', artistName: 'JENNIE', + visibility: 'public', }, - ] satisfies Prisma.JsonArray, - }, - { - id: 'log-1', - title: '광안리의 순간', - placeName: '광안리', - representativeTrackId: 'seoul-city', - createdAt: '2026-05-25T00:00:00.000Z', - momentCount: 1, - sessionId: 'seed-session', - backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', - discImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', - lat: 35.1532, - lng: 129.1187, - recordedAt: '2026-05-25T00:00:00.000Z', - templateId: 'album', - visibility: 'private', - moments: [ { - id: 'log-1', - imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', - location: { lat: 35.1532, lng: 129.1187 }, - placeName: '광안리', - recordedAt: '2026-05-25T00:00:00.000Z', - trackTitle: 'Seoul City', - artistName: 'JENNIE', + id: 'log-2', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + location: { lat: 35.1587, lng: 129.1604 }, + placeName: '해운대', + recordedAt: '2026-05-25T00:10:00.000Z', + templateId: 'film', + trackTitle: '밤편지', + artistName: '아이유', + visibility: 'public', + }, + { + id: 'log-3', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg', + location: { lat: 35.157, lng: 129.063 }, + placeName: '전포', + recordedAt: '2026-05-25T00:20:00.000Z', + templateId: 'lp', + trackTitle: '서울의 밤', + artistName: '10cm', + visibility: 'public', }, ] satisfies Prisma.JsonArray, }, diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 8ddceb6..d81717d 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -44,8 +44,9 @@ export async function authMiddleware(req: Request, _res: Response, next: NextFun if (env.USE_MOCK_DB) { const passwordUser = mockDb.passwordUsers.find((user) => user.id === userId); + const isDefaultMockUser = userId === 'mock-user-local'; - if (userId !== mockDb.user.id && !passwordUser) { + if (!isDefaultMockUser && !passwordUser) { throw unauthorized(ERROR_MESSAGES.INVALID_TOKEN); } @@ -55,7 +56,11 @@ export async function authMiddleware(req: Request, _res: Response, next: NextFun provider: 'email', providerUserId: passwordUser.email, } - : mockDb.user; + : { + id: 'mock-user-local', + provider: mockDb.user.provider, + providerUserId: mockDb.user.providerUserId, + }; next(); return; } diff --git a/src/mock/mock-db.ts b/src/mock/mock-db.ts index 2271dba..7b7af75 100644 --- a/src/mock/mock-db.ts +++ b/src/mock/mock-db.ts @@ -33,8 +33,10 @@ type MockMomentLog = { sessionId?: string; source: 'camera'; syncStatus: 'failed' | 'pending' | 'synced'; + templateId: string; trackSnapshot?: MockTrack; travelMode?: string; + visibility: 'private' | 'public'; }; type MockLibraryTrackState = { @@ -61,6 +63,8 @@ type MockRecap = { representativeTrackId: string; routePoints?: MockRoutePoint[]; sessionId?: string; + thumbnailMomentId?: string; + travelSessionId?: string; shareImageUrl?: string; templateId: string; title: string; @@ -251,7 +255,10 @@ function createMockDb() { moodTags: [...log.moodTags], source: 'camera' as const, syncStatus: 'synced' as const, + templateId: log.templateId, trackSnapshot: trackById.get(log.trackId), + travelMode: log.travelMode, + visibility: log.visibility, })) as MockMomentLog[], recommendationEvents: [] as Array<{ context: Record; @@ -266,7 +273,7 @@ function createMockDb() { }>, recaps: recaps.map((recap) => { const routePoints = 'routePoints' in recap - ? (recap.routePoints as MockRoutePoint[] | undefined) + ? recap.routePoints.map((point) => ({ ...point })) : undefined; return { @@ -278,6 +285,7 @@ function createMockDb() { createdAt: new Date(recap.createdAt), momentCount: recap.momentCount, sessionId: recap.sessionId, + thumbnailMomentId: recap.thumbnailMomentId, backgroundImageUrl: recap.backgroundImageUrl, discImageUrl: recap.discImageUrl, recordedAt: new Date(recap.recordedAt), diff --git a/src/routes/index.ts b/src/routes/index.ts index 454a726..ce99e3a 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -68,6 +68,7 @@ export function createApiRouter() { ); router.get('/v1/me', authMiddleware, asyncHandler(meController.getMe)); + router.delete('/v1/me', authMiddleware, asyncHandler(meController.deleteAccount)); router.get('/v1/me/profile', authMiddleware, asyncHandler(meController.getProfile)); router.put( '/v1/me/profile', @@ -82,12 +83,24 @@ export function createApiRouter() { asyncHandler(meController.migrateLocalData), ); + router.get( + '/v1/tour/places', + authMiddleware, + validate({ query: tourValidators.searchQuery }), + asyncHandler(tourController.searchPlaces), + ); router.get( '/v1/tour/nearby-places', authMiddleware, validate({ query: tourValidators.nearbyQuery }), asyncHandler(tourController.getNearbyPlaces), ); + router.get( + '/v1/tour/reverse-geocode', + authMiddleware, + validate({ query: tourValidators.reverseGeocodeQuery }), + asyncHandler(tourController.reverseGeocodeLocation), + ); router.get( '/v1/home/featured-playlists', @@ -270,6 +283,15 @@ export function createApiRouter() { }), asyncHandler(recapController.updateRecapVisibility), ); + router.patch( + '/v1/recaps/:recapId/thumbnail', + authMiddleware, + validate({ + params: recapValidators.recapParams, + body: recapValidators.thumbnailBody, + }), + asyncHandler(recapController.updateRecapThumbnail), + ); router.post( '/v1/recaps/:recapId/share-events', authMiddleware, diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index 9610853..8760a82 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -28,9 +28,11 @@ type MomentLogUpdateInput = { placeId?: string | null; placeName?: string | null; sessionId?: string | null; + templateId?: string; trackId?: string; trackTitle?: string; travelMode?: string | null; + visibility?: RecapVisibility; }; type RecapListScope = 'all' | 'mine' | 'others'; @@ -44,6 +46,7 @@ type RoutePointDto = { }; const RECAP_DISCOVERY_RADIUS_METERS = 300; +const NO_MUSIC_TRACK_ID = 'soundlog-no-music'; const TRAVEL_MATE_REQUEST_COOLDOWN_MS = 24 * 60 * 60 * 1000; const CLOSED_TRAVEL_MATE_REQUEST_STATUSES = ['cancelled', 'declined', 'expired']; @@ -224,6 +227,8 @@ function momentLogToDto(log: (typeof mockDb.momentLogs)[number]) { placeId: log.placeId, placeName: log.placeName, note: log.note, + recapVisibility: log.visibility, + templateId: log.templateId, track: log.trackSnapshot, travelMode: log.travelMode, moodTags: log.moodTags, @@ -244,21 +249,94 @@ function musicLogItemFromMoment(log: (typeof mockDb.momentLogs)[number]) { }); } -function recapItemToDto(recap: (typeof mockDb.recaps)[number]) { +type MockStoredRecapMoment = { + artistName: string; + id: string; + imageUrl?: string; + location?: { lat: number; lng: number }; + placeName: string; + recordedAt: string; + templateId?: string; + track?: TrackDto; + trackTitle: string; + visibility?: RecapVisibility; +}; + +function getMockRecapMoments(recap: (typeof mockDb.recaps)[number]) { + return (recap.moments ?? []).filter( + (moment): moment is MockStoredRecapMoment => + Boolean( + moment && + typeof moment === 'object' && + typeof (moment as Partial).id === 'string', + ), + ); +} + +function getMockVisibleRecapMoments( + recap: (typeof mockDb.recaps)[number], + viewerId?: string, +) { + const moments = getMockRecapMoments(recap); + + if (recap.userId === viewerId) { + return moments; + } + + return moments.filter( + (moment) => (moment.visibility ?? recap.visibility) === 'public', + ); +} + +function getMockRecapThumbnailMoment( + recap: (typeof mockDb.recaps)[number], + viewerId?: string, +) { + const visibleMoments = getMockVisibleRecapMoments(recap, viewerId); + + return ( + visibleMoments.find((moment) => moment.id === recap.thumbnailMomentId) ?? + visibleMoments[0] + ); +} + +function recapItemToDto( + recap: (typeof mockDb.recaps)[number], + viewerId?: string, +) { const track = findMockTrack(recap.representativeTrackId); if (!track) { throw notFound(ERROR_MESSAGES.REPRESENTATIVE_TRACK_NOT_FOUND); } + const isMine = recap.userId === viewerId; + const visibleMoments = getMockVisibleRecapMoments(recap, viewerId); + const publicRepresentative = isMine ? undefined : visibleMoments.at(-1); + const thumbnailMoment = getMockRecapThumbnailMoment(recap, viewerId); + const publicTrack = publicRepresentative?.track ?? ( + publicRepresentative + ? { + artist: publicRepresentative.artistName, + fallbackColor: '#252A38', + id: `recap-moment-track-${publicRepresentative.id}`, + title: publicRepresentative.trackTitle, + } + : undefined + ); + return compact({ id: recap.id, - title: recap.title, - placeName: recap.placeName, - representativeTrack: trackToDto(track), + title: publicRepresentative && recap.sessionId + ? `${publicRepresentative.placeName} 여행 로그` + : recap.title, + placeName: publicRepresentative?.placeName ?? recap.placeName, + representativeTrack: publicTrack ?? trackToDto(track), createdAt: recap.createdAt.toISOString(), - momentCount: recap.momentCount, + momentCount: isMine ? recap.momentCount : visibleMoments.length, sessionId: recap.sessionId, + backgroundImageUrl: thumbnailMoment?.imageUrl ?? (isMine ? recap.backgroundImageUrl : undefined), + thumbnailMomentId: thumbnailMoment?.id, visibility: recap.visibility, }); } @@ -271,26 +349,35 @@ function recapShareToDto(recap: (typeof mockDb.recaps)[number], viewerId?: strin } const canViewRoutePoints = recap.userId === viewerId; + const visibleMoments = getMockVisibleRecapMoments(recap, viewerId); + const publicRepresentative = canViewRoutePoints ? undefined : visibleMoments.at(-1); + const thumbnailMoment = getMockRecapThumbnailMoment(recap, viewerId); return compact({ id: recap.id, - placeName: recap.placeName, - trackTitle: track.title, - artistName: track.artist, - backgroundImageUrl: recap.backgroundImageUrl, - discImageUrl: recap.discImageUrl, - moments: recap.moments, - recordedAt: (recap.recordedAt ?? recap.createdAt).toISOString(), + isMine: canViewRoutePoints, + placeName: publicRepresentative?.placeName ?? recap.placeName, + trackTitle: publicRepresentative?.trackTitle ?? track.title, + artistName: publicRepresentative?.artistName ?? track.artist, + backgroundImageUrl: thumbnailMoment?.imageUrl ?? (canViewRoutePoints ? recap.backgroundImageUrl : undefined), + discImageUrl: publicRepresentative?.imageUrl ?? recap.discImageUrl, + moments: visibleMoments, + recordedAt: publicRepresentative?.recordedAt ?? + (recap.recordedAt ?? recap.createdAt).toISOString(), routePoints: canViewRoutePoints ? recap.routePoints : undefined, + sessionId: recap.sessionId, shareImageUrl: recap.shareImageUrl, + templateId: recap.templateId, + thumbnailMomentId: thumbnailMoment?.id, visibility: recap.visibility, }); } -function getMockRecapMomentLocation(recap: (typeof mockDb.recaps)[number]) { - const moments = Array.isArray(recap.moments) - ? (recap.moments as Array<{ location?: { lat?: unknown; lng?: unknown } }>) - : []; +function getMockRecapMomentLocation( + recap: (typeof mockDb.recaps)[number], + viewerId?: string, +) { + const moments = getMockVisibleRecapMoments(recap, viewerId); const location = moments.find( (moment) => typeof moment.location?.lat === 'number' && @@ -307,15 +394,23 @@ function getMockRecapMomentLocation(recap: (typeof mockDb.recaps)[number]) { }; } -function getMockRecapLocation(recap: (typeof mockDb.recaps)[number]) { - if (recap.lat !== undefined && recap.lng !== undefined) { +function getMockRecapLocation( + recap: (typeof mockDb.recaps)[number], + viewerId?: string, +) { + const canViewAllMoments = viewerId === undefined || recap.userId === viewerId; + + if (canViewAllMoments && recap.lat !== undefined && recap.lng !== undefined) { return { lat: recap.lat, lng: recap.lng, }; } - return getMockRecapMomentLocation(recap); + return getMockRecapMomentLocation( + recap, + canViewAllMoments ? recap.userId : viewerId, + ); } function assertMockPublicRecapHasLocation( @@ -327,12 +422,111 @@ function assertMockPublicRecapHasLocation( } } +function ensureMockTrackFromMoment(moment?: (typeof mockDb.momentLogs)[number]) { + const snapshot = moment?.trackSnapshot; + + if (snapshot && !findMockTrack(snapshot.id)) { + mockDb.tracks.push({ + ...snapshot, + fallbackColor: snapshot.fallbackColor ?? '#252A38', + }); + } + + return snapshot?.id ?? 'seoul-city'; +} + +function refreshMockRecapAggregates(input: { + momentIds: string[]; + sessionIds: Array; + userId: string; +}) { + const momentIds = new Set(input.momentIds); + const sessionIds = new Set(input.sessionIds.filter((id): id is string => Boolean(id))); + + mockDb.recaps = mockDb.recaps.flatMap((recap) => { + const isAffected = + recap.userId === input.userId && + ( + (recap.travelSessionId + ? sessionIds.has(recap.travelSessionId) + : false) || + getMockRecapMoments(recap).some((moment) => momentIds.has(moment.id)) + ); + + if (!isAffected) { + return [recap]; + } + + const storedMomentIds = new Set(getMockRecapMoments(recap).map((moment) => moment.id)); + const moments = mockDb.momentLogs + .filter((moment) => + recap.travelSessionId + ? moment.sessionId === recap.travelSessionId && recap.userId === input.userId + : storedMomentIds.has(moment.id) && !moment.sessionId, + ) + .sort((first, second) => first.createdAt.getTime() - second.createdAt.getTime()); + + if (moments.length === 0) { + return []; + } + + const representativeMoment = moments.at(-1)!; + const thumbnailMoment = + moments.find((moment) => moment.id === recap.thumbnailMomentId) ?? moments[0]!; + const locatedMoment = + representativeMoment.lat !== undefined && representativeMoment.lng !== undefined + ? representativeMoment + : moments.find((moment) => moment.lat !== undefined && moment.lng !== undefined); + const hasPublicLocatedMoment = moments.some( + (moment) => + moment.visibility === 'public' && + moment.lat !== undefined && + moment.lng !== undefined, + ); + + return [{ + ...recap, + backgroundImageUrl: thumbnailMoment.photoUrl, + discImageUrl: representativeMoment.photoUrl, + lat: locatedMoment?.lat, + lng: locatedMoment?.lng, + momentCount: moments.length, + moments: moments.map((moment) => ({ + id: moment.id, + imageUrl: moment.photoUrl, + location: + moment.lat !== undefined && moment.lng !== undefined + ? { lat: moment.lat, lng: moment.lng } + : undefined, + placeName: moment.placeName ?? '위치 없음', + trackTitle: moment.trackSnapshot?.title ?? '저장된 순간', + artistName: moment.trackSnapshot?.artist ?? '음악 없음', + recordedAt: moment.createdAt.toISOString(), + templateId: moment.templateId, + track: moment.trackSnapshot, + visibility: moment.visibility, + })), + placeName: representativeMoment.placeName ?? 'Soundlog', + recordedAt: representativeMoment.createdAt, + representativeTrackId: ensureMockTrackFromMoment(representativeMoment), + thumbnailMomentId: thumbnailMoment.id, + templateId: recap.travelSessionId + ? recap.templateId + : representativeMoment.templateId, + visibility: + recap.visibility === 'public' && !hasPublicLocatedMoment + ? 'private' as const + : recap.visibility, + }]; + }); +} + function mockRecapMapMarkerToDto( recap: (typeof mockDb.recaps)[number], viewerId: string, origin?: { lat: number; lng: number }, ) { - const location = getMockRecapLocation(recap); + const location = getMockRecapLocation(recap, viewerId); const track = findMockTrack(recap.representativeTrackId); if (!location || !track) { @@ -340,21 +534,36 @@ function mockRecapMapMarkerToDto( } const isMine = recap.userId === viewerId; + const publicRepresentative = isMine + ? undefined + : getMockVisibleRecapMoments(recap, viewerId).at(-1); + const thumbnailMoment = getMockRecapThumbnailMoment(recap, viewerId); + const publicTrack = publicRepresentative?.track ?? ( + publicRepresentative + ? { + artist: publicRepresentative.artistName, + id: `recap-moment-track-${publicRepresentative.id}`, + title: publicRepresentative.trackTitle, + } + : undefined + ); return compact({ id: `marker-${recap.id}`, recapId: recap.id, - title: recap.title, - placeName: recap.placeName, + title: publicRepresentative && recap.sessionId + ? `${publicRepresentative.placeName} 여행 로그` + : recap.title, + placeName: publicRepresentative?.placeName ?? recap.placeName, ownerAlias: isMine ? '나' : 'Soundlog 여행자', location, - trackTitle: track.title, - artistName: track.artist, - templateId: recap.templateId, + trackTitle: publicTrack?.title ?? track.title, + artistName: publicTrack?.artist ?? track.artist, + templateId: publicRepresentative?.templateId ?? recap.templateId, visibility: recap.visibility, distanceMeters: origin ? Math.round(distanceMeters(origin, location)) : undefined, - imageUrl: recap.backgroundImageUrl, - createdAt: recap.createdAt.toISOString(), + imageUrl: thumbnailMoment?.imageUrl ?? (isMine ? recap.backgroundImageUrl : undefined), + createdAt: publicRepresentative?.recordedAt ?? recap.createdAt.toISOString(), }); } @@ -608,6 +817,26 @@ function toPublicPlaceSource(source: string) { return source === 'mock' ? 'seed' : source; } +function normalizeMoodLabel(value?: string) { + const normalized = value?.trim(); + + if (!normalized) { + return undefined; + } + + return normalized === '청량한' ? '시원한' : normalized; +} + +function matchesMoodFilter(itemMoods: readonly string[], moodFilter?: string) { + const normalizedFilter = normalizeMoodLabel(moodFilter); + + if (!normalizedFilter || normalizedFilter === '전체') { + return true; + } + + return itemMoods.some((mood) => normalizeMoodLabel(mood) === normalizedFilter); +} + function scoreMoodRecommendation( item: (typeof mockDb.moodRecommendations)[number], params: { @@ -615,7 +844,6 @@ function scoreMoodRecommendation( preferredGenres?: string[]; preferredMoods?: string[]; recommendationMode?: 'everyday' | 'travel'; - topFilter?: string; travelStyles?: string[]; }, ) { @@ -623,19 +851,11 @@ function scoreMoodRecommendation( const travelModeWeight = params.recommendationMode === 'travel' ? 2.4 : 1; const tasteWeight = params.recommendationMode === 'travel' ? 0.7 : 1.4; - if (params.topFilter && params.topFilter !== '전체' && item.moods.includes(params.topFilter)) { - score += 8; - } - - if (params.moodFilter && params.moodFilter !== '전체' && item.moods.includes(params.moodFilter)) { - score += 8; - } - score += (params.preferredGenres ?? []).filter((genre) => item.genres.includes(genre), ).length * 3 * tasteWeight; score += (params.preferredMoods ?? []).filter((mood) => - item.moods.includes(mood), + item.moods.some((itemMood) => normalizeMoodLabel(itemMood) === normalizeMoodLabel(mood)), ).length * 2 * tasteWeight; score += (params.travelStyles ?? []).filter((style) => item.travelStyles.includes(style), @@ -681,6 +901,50 @@ export const mockSoundlogService = { return this.getMyProfile(); }, + async deleteMyAccount(userId: string) { + mockDb.passwordUsers = mockDb.passwordUsers.filter((user) => user.id !== userId); + mockDb.libraryTrackStates = []; + mockDb.momentLogs = []; + mockDb.recaps = mockDb.recaps.filter((recap) => recap.userId !== userId); + mockDb.recommendationEvents = mockDb.recommendationEvents.filter( + (event) => event.userId !== userId, + ); + mockDb.travelSessions = mockDb.travelSessions.filter( + (session) => session.userId !== userId, + ); + const ownedRoomIds = new Set( + mockDb.travelRooms.filter((room) => room.ownerId === userId).map((room) => room.id), + ); + const ownedRoomMomentIds = new Set( + mockDb.travelRoomMoments + .filter((moment) => ownedRoomIds.has(moment.roomId)) + .map((moment) => moment.id), + ); + mockDb.travelRooms = mockDb.travelRooms.filter((room) => !ownedRoomIds.has(room.id)); + mockDb.travelRoomMembers = mockDb.travelRoomMembers.filter( + (member) => member.userId !== userId && !ownedRoomIds.has(member.roomId), + ); + mockDb.travelRoomMoments = mockDb.travelRoomMoments.filter( + (moment) => moment.userId !== userId && !ownedRoomIds.has(moment.roomId), + ); + mockDb.travelRoomMomentComments = mockDb.travelRoomMomentComments.filter( + (comment) => comment.userId !== userId && !ownedRoomMomentIds.has(comment.momentId), + ); + mockDb.soundMapPins = mockDb.soundMapPins.filter((pin) => pin.userId !== userId); + mockDb.travelMateRequests = mockDb.travelMateRequests.filter( + (request) => request.requesterId !== userId && request.targetUserId !== userId, + ); + mockDb.communityBlocks = mockDb.communityBlocks.filter( + (block) => block.blockerId !== userId && block.blockedUserId !== userId, + ); + mockDb.communityReports = mockDb.communityReports.filter( + (report) => report.reporterId !== userId && report.targetUserId !== userId, + ); + mockDb.refreshTokens = []; + + return { deleted: true }; + }, + async migrateLocalData(_userId: string, input: { idempotencyKey: string; libraryTrackCount: number; @@ -698,15 +962,77 @@ export const mockSoundlogService = { }; }, - async getNearbyPlaces(params: { lat: number; limit?: number }) { - const isSouthernContext = params.lat < 36.5; + async getNearbyPlaces(params: { + lat: number; + limit?: number; + lng: number; + radiusMeters?: number; + }) { + const origin = { lat: params.lat, lng: params.lng }; + const radiusMeters = params.radiusMeters ?? 2000; return [...mockDb.places] - .sort((first, second) => { - const firstScore = isSouthernContext && first.address?.startsWith('부산') ? -1 : 0; - const secondScore = isSouthernContext && second.address?.startsWith('부산') ? -1 : 0; - return firstScore - secondScore; + .flatMap((place) => { + if (place.lat === undefined || place.lng === undefined) { + return []; + } + + const distance = Math.round( + distanceMeters(origin, { lat: place.lat, lng: place.lng }), + ); + + return distance <= radiusMeters ? [{ distance, place }] : []; }) + .sort((first, second) => first.distance - second.distance) + .slice(0, getLimit(params.limit, 10)) + .map(({ distance, place }) => + compact({ + id: toPublicPlaceId(place.id), + title: place.title, + address: place.address, + category: place.category, + contentType: place.contentType, + distanceMeters: distance, + imageUrl: place.imageUrl, + location: + place.lat !== undefined && place.lng !== undefined + ? { lat: place.lat, lng: place.lng } + : undefined, + overview: place.overview, + source: toPublicPlaceSource(place.source), + }), + ); + }, + + async reverseGeocodeLocation(params: { lat: number; lng: number }) { + const isSanFrancisco = + params.lat >= 37.6 && + params.lat <= 37.9 && + params.lng >= -122.6 && + params.lng <= -122.2; + + return { + address: isSanFrancisco + ? '미국 캘리포니아주 샌프란시스코' + : '대한민국 현재 지역', + attribution: '© OpenStreetMap contributors', + category: '현재 지역', + id: `reverse-${params.lat.toFixed(4)}-${params.lng.toFixed(4)}`, + location: { lat: params.lat, lng: params.lng }, + source: 'reverse-geocode' as const, + title: isSanFrancisco ? '샌프란시스코' : '현재 지역', + }; + }, + + async searchPlaces(params: { limit?: number; query: string }) { + const query = params.query.trim().toLocaleLowerCase(); + + return mockDb.places + .filter((place) => + [place.title, place.address, place.category] + .filter(Boolean) + .some((value) => String(value).toLocaleLowerCase().includes(query)), + ) .slice(0, getLimit(params.limit, 10)) .map((place) => compact({ @@ -742,6 +1068,7 @@ export const mockSoundlogService = { : undefined; return [...mockDb.playlists] + .filter((playlist) => playlist.source !== 'personalized') .sort((first, second) => { if (first.id === preferredId) { return -1; @@ -770,10 +1097,10 @@ export const mockSoundlogService = { preferredGenres?: string[]; preferredMoods?: string[]; recommendationMode?: 'everyday' | 'travel'; - topFilter?: string; travelStyles?: string[]; }) { return [...mockDb.moodRecommendations] + .filter((item) => matchesMoodFilter(item.moods, params.moodFilter)) .sort((first, second) => scoreMoodRecommendation(second, params) - scoreMoodRecommendation(first, params)) .slice(0, getLimit(params.limit)) .map((recommendation) => { @@ -789,7 +1116,9 @@ export const mockSoundlogService = { subtitle: 'subtitle' in recommendation ? recommendation.subtitle : undefined, color: recommendation.color, genres: recommendation.genres, + imageUrl: recommendation.imageUrl, moods: recommendation.moods, + playlistId: recommendation.playlistId, travelStyles: recommendation.travelStyles, track: trackToDto(track), }; @@ -1001,13 +1330,22 @@ export const mockSoundlogService = { placeName?: string; note?: string; sessionId?: string; + templateId?: string; trackId?: string; trackTitle?: string; travelMode?: string; + visibility?: RecapVisibility; }, idempotencyKey?: string) { return withMockIdempotency( { idempotencyKey, scope: 'moment-log.create', userId }, () => { + assertMockPublicRecapHasLocation( + input.visibility, + input.lat !== undefined && input.lng !== undefined + ? { lat: input.lat, lng: input.lng } + : undefined, + ); + const track = findMockTrack(input.trackId); const log = { id: createPublicId('moment'), @@ -1022,6 +1360,7 @@ export const mockSoundlogService = { placeId: input.placeId, placeName: input.placeName, note: input.note, + templateId: input.templateId ?? 'album', trackSnapshot: track ?? (input.trackTitle @@ -1035,9 +1374,15 @@ export const mockSoundlogService = { moodTags: input.moodTags, source: 'camera' as const, syncStatus: 'synced' as const, + visibility: input.visibility ?? 'private', }; mockDb.momentLogs.unshift(log); + refreshMockRecapAggregates({ + momentIds: [log.id], + sessionIds: [log.sessionId], + userId, + }); return momentLogToDto(log); }, @@ -1055,6 +1400,21 @@ export const mockSoundlogService = { throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); } + const previousSessionId = log.sessionId; + + if (hasOwn(input, 'lat') || hasOwn(input, 'lng') || input.visibility) { + const nextLat = hasOwn(input, 'lat') ? input.lat : log.lat; + const nextLng = hasOwn(input, 'lng') ? input.lng : log.lng; + const nextVisibility = input.visibility ?? log.visibility; + + assertMockPublicRecapHasLocation( + nextVisibility, + nextLat !== null && nextLat !== undefined && nextLng !== null && nextLng !== undefined + ? { lat: nextLat, lng: nextLng } + : undefined, + ); + } + if (input.createdAt) { log.createdAt = new Date(input.createdAt); } @@ -1091,10 +1451,18 @@ export const mockSoundlogService = { log.sessionId = input.sessionId ?? undefined; } + if (input.templateId) { + log.templateId = input.templateId; + } + if (hasOwn(input, 'travelMode')) { log.travelMode = input.travelMode ?? undefined; } + if (input.visibility) { + log.visibility = input.visibility; + } + const shouldUpdateTrackSnapshot = hasOwn(input, 'trackId') || hasOwn(input, 'trackTitle') || @@ -1112,6 +1480,12 @@ export const mockSoundlogService = { : undefined); } + refreshMockRecapAggregates({ + momentIds: [log.id], + sessionIds: [previousSessionId, log.sessionId], + userId: _userId, + }); + return momentLogToDto(log); }, @@ -1123,6 +1497,11 @@ export const mockSoundlogService = { } log.photoUrl = `${env.UPLOAD_PUBLIC_BASE_URL}${photoPath}`; + refreshMockRecapAggregates({ + momentIds: [log.id], + sessionIds: [log.sessionId], + userId: _userId, + }); return momentLogToDto(log); }, @@ -1135,23 +1514,33 @@ export const mockSoundlogService = { } log.photoUrl = undefined; + refreshMockRecapAggregates({ + momentIds: [log.id], + sessionIds: [log.sessionId], + userId: _userId, + }); return momentLogToDto(log); }, - async deleteMomentLog(_userId: string, momentLogId: string) { + async deleteMomentLog(userId: string, momentLogId: string) { const index = mockDb.momentLogs.findIndex((moment) => moment.id === momentLogId); if (index === -1) { throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); } - mockDb.momentLogs.splice(index, 1); + const [deletedMoment] = mockDb.momentLogs.splice(index, 1); mockDb.travelRoomMoments.forEach((moment) => { if (moment.momentLogId === momentLogId) { moment.momentLogId = undefined; } }); + refreshMockRecapAggregates({ + momentIds: [momentLogId], + sessionIds: [deletedMoment?.sessionId], + userId, + }); }, async createRecommendationEvents(userId: string, input: { @@ -1903,6 +2292,7 @@ export const mockSoundlogService = { }) { const scope = params.scope ?? 'mine'; const recaps = mockDb.recaps + .filter((recap) => Boolean(recap.sessionId)) .filter((recap) => scope === 'mine' ? recap.userId === userId @@ -1910,6 +2300,10 @@ export const mockSoundlogService = { ? recap.userId !== userId && recap.visibility === 'public' : recap.userId === userId || recap.visibility === 'public', ) + .filter( + (recap) => + recap.userId === userId || getMockVisibleRecapMoments(recap, userId).length > 0, + ) .sort( (first, second) => second.createdAt.getTime() - first.createdAt.getTime(), ); @@ -1917,7 +2311,7 @@ export const mockSoundlogService = { const page = paginateByCursor(recaps, limit, params.cursor); return { - data: page.items.map(recapItemToDto), + data: page.items.map((recap) => recapItemToDto(recap, userId)), page: { limit, nextCursor: page.nextCursor, @@ -1933,7 +2327,9 @@ export const mockSoundlogService = { }) { const scope = params.scope ?? 'public'; const origin = - params.lat !== undefined && params.lng !== undefined + scope === 'public' && + params.lat !== undefined && + params.lng !== undefined ? { lat: params.lat, lng: params.lng } : undefined; const radiusMeters = RECAP_DISCOVERY_RADIUS_METERS; @@ -1947,10 +2343,15 @@ export const mockSoundlogService = { .flatMap((recap) => { const marker = mockRecapMapMarkerToDto(recap, userId, origin); - return marker ? [marker] : []; + return marker && (recap.userId === userId || getMockVisibleRecapMoments(recap, userId).length) + ? [marker] + : []; }) .filter((marker) => - origin ? typeof marker.distanceMeters === 'number' && marker.distanceMeters <= radiusMeters : true, + origin + ? typeof marker.distanceMeters === 'number' && + marker.distanceMeters <= radiusMeters + : true, ); }, @@ -1966,14 +2367,81 @@ export const mockSoundlogService = { return withMockIdempotency( { idempotencyKey, scope: 'recap.create', userId }, () => { - const moments = mockDb.momentLogs.filter((moment) => { - if (input.momentLogIds?.length) { - return input.momentLogIds.includes(moment.id); + if (!input.sessionId && !input.momentLogIds?.length) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_REQUIRES_CAPTURE); + } + + const moments = mockDb.momentLogs + .filter((moment) => { + if (input.momentLogIds?.length) { + return input.momentLogIds.includes(moment.id); + } + + return input.sessionId ? moment.sessionId === input.sessionId : true; + }) + .sort((first, second) => first.createdAt.getTime() - second.createdAt.getTime()); + const requestedMomentIds = Array.from(new Set(input.momentLogIds ?? [])); + + if (requestedMomentIds.length > 0 && moments.length !== requestedMomentIds.length) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_CAPTURE_MISMATCH); + } + + if ( + !input.sessionId && + (requestedMomentIds.length !== 1 || moments.some((moment) => moment.sessionId)) + ) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_CAPTURE_MISMATCH); + } + + if (moments.length === 0) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_REQUIRES_CAPTURE); + } + + let travelSession = input.sessionId + ? mockDb.travelSessions.find( + (session) => session.id === input.sessionId, + ) + : undefined; + + if (travelSession && travelSession.userId !== userId) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_CAPTURE_MISMATCH); + } + + if (input.sessionId && !travelSession) { + const recoveredRoutePoints = normalizeRoutePoints(input.routePoints); + const recordedAt = moments.at(-1)?.createdAt ?? new Date(); + + travelSession = { + endedAt: recoveredRoutePoints?.at(-1) + ? new Date(recoveredRoutePoints.at(-1)!.recordedAt) + : recordedAt, + id: input.sessionId, + routePoints: recoveredRoutePoints, + startedAt: recoveredRoutePoints?.[0] + ? new Date(recoveredRoutePoints[0].recordedAt) + : moments[0]?.createdAt ?? recordedAt, + status: 'ended', + userId, + }; + mockDb.travelSessions.push(travelSession); + } + + if (input.sessionId) { + const existingLog = mockDb.recaps.find( + (recap) => recap.travelSessionId === input.sessionId, + ); + + if (existingLog) { + if (existingLog.userId !== userId) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_CAPTURE_MISMATCH); + } + + return recapItemToDto(existingLog, userId); } + } - return input.sessionId ? moment.sessionId === input.sessionId : true; - }); - const firstMoment = moments[0]; + const representativeMoment = moments.at(-1)!; + const thumbnailMoment = moments[0]!; const candidateTrackIds = input.representativeTrackId ? [input.representativeTrackId] : Array.from( @@ -1986,50 +2454,77 @@ export const mockSoundlogService = { ); const representativeTrackId = input.representativeTrackId ?? - candidateTrackIds.find((trackId) => Boolean(findMockTrack(trackId))) ?? - 'seoul-city'; + candidateTrackIds[0] ?? + NO_MUSIC_TRACK_ID; + + const representativeTrackSnapshot = [...moments] + .reverse() + .map((moment) => moment.trackSnapshot) + .find((track) => track?.id === representativeTrackId); + + if (!findMockTrack(representativeTrackId) && representativeTrackSnapshot) { + mockDb.tracks.push({ + ...representativeTrackSnapshot, + fallbackColor: representativeTrackSnapshot.fallbackColor ?? '#252A38', + }); + } + + if (!findMockTrack(representativeTrackId) && representativeTrackId === NO_MUSIC_TRACK_ID) { + mockDb.tracks.push({ + artist: 'Soundlog', + fallbackColor: '#252A38', + id: NO_MUSIC_TRACK_ID, + title: '음악 없음', + }); + } if (!findMockTrack(representativeTrackId)) { throw notFound(ERROR_MESSAGES.REPRESENTATIVE_TRACK_NOT_FOUND); } - const firstMomentLocation = - firstMoment?.lat !== undefined && firstMoment?.lng !== undefined - ? { lat: firstMoment.lat, lng: firstMoment.lng } + const representativeMomentLocation = + representativeMoment.lat !== undefined && representativeMoment.lng !== undefined + ? { lat: representativeMoment.lat, lng: representativeMoment.lng } : undefined; - const travelSession = input.sessionId - ? mockDb.travelSessions.find( - (session) => session.id === input.sessionId && session.userId === userId, - ) - : undefined; const routePoints = normalizeRoutePoints(input.routePoints) ?? normalizeRoutePoints(travelSession?.routePoints); const firstRoutePoint = input.routePoints?.[0] ?? travelSession?.routePoints?.[0]; - const recapLocation = firstMomentLocation ?? ( + const recapLocation = representativeMomentLocation ?? ( firstRoutePoint ? { lat: firstRoutePoint.lat, lng: firstRoutePoint.lng } : undefined ); - assertMockPublicRecapHasLocation(input.visibility, recapLocation); + const hasPublicLocatedMoment = moments.some( + (moment) => + moment.visibility === 'public' && + moment.lat !== undefined && + moment.lng !== undefined, + ); + + if (input.visibility === 'public' && !hasPublicLocatedMoment) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_PUBLIC_CAPTURE_REQUIRED); + } const recap = { id: createPublicId('recap'), userId, - title: input.title ?? `${firstMoment?.placeName ?? '여행'}의 사운드`, - placeName: firstMoment?.placeName ?? 'Soundlog', + title: input.title ?? `${representativeMoment.placeName ?? '여행'}의 사운드`, + placeName: representativeMoment.placeName ?? 'Soundlog', representativeTrackId, createdAt: new Date(), momentCount: moments.length, sessionId: input.sessionId, - backgroundImageUrl: firstMoment?.photoUrl, - discImageUrl: firstMoment?.photoUrl, + travelSessionId: input.sessionId, + backgroundImageUrl: thumbnailMoment.photoUrl, + discImageUrl: representativeMoment.photoUrl, lat: recapLocation?.lat, lng: recapLocation?.lng, - recordedAt: firstMoment?.createdAt ?? new Date(), + recordedAt: representativeMoment.createdAt, routePoints, templateId: input.templateId ?? 'album', + thumbnailMomentId: thumbnailMoment.id, visibility: input.visibility ?? 'private', moments: moments.map((moment) => ({ id: moment.id, @@ -2042,12 +2537,15 @@ export const mockSoundlogService = { trackTitle: moment.trackSnapshot?.title ?? '저장된 순간', artistName: moment.trackSnapshot?.artist ?? '음악 없음', recordedAt: moment.createdAt.toISOString(), + templateId: moment.templateId, + track: moment.trackSnapshot, + visibility: moment.visibility, })), }; mockDb.recaps.unshift(recap); - return recapItemToDto(recap); + return recapItemToDto(recap, userId); }, ); }, @@ -2061,6 +2559,10 @@ export const mockSoundlogService = { throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); } + if (recap.userId !== userId && getMockVisibleRecapMoments(recap, userId).length === 0) { + throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); + } + return recapShareToDto(recap, userId); }, @@ -2075,11 +2577,87 @@ export const mockSoundlogService = { throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); } - assertMockPublicRecapHasLocation(input.visibility, getMockRecapLocation(recap)); + const memberMomentIds = new Set(getMockRecapMoments(recap).map((moment) => moment.id)); + + if (recap.sessionId && input.visibility === 'public') { + const hasPublicLocatedMoment = getMockRecapMoments(recap).some( + (moment) => + moment.visibility === 'public' && + typeof moment.location?.lat === 'number' && + typeof moment.location?.lng === 'number', + ); + + if (!hasPublicLocatedMoment) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_PUBLIC_CAPTURE_REQUIRED); + } + } + + if (!recap.sessionId) { + const memberMoments = mockDb.momentLogs.filter((moment) => memberMomentIds.has(moment.id)); + const locatedMoment = memberMoments.find( + (moment) => moment.lat !== undefined && moment.lng !== undefined, + ); + + assertMockPublicRecapHasLocation( + input.visibility, + locatedMoment && locatedMoment.lat !== undefined && locatedMoment.lng !== undefined + ? { lat: locatedMoment.lat, lng: locatedMoment.lng } + : undefined, + ); + memberMoments.forEach((moment) => { + moment.visibility = input.visibility; + }); + } recap.visibility = input.visibility; + refreshMockRecapAggregates({ + momentIds: [...memberMomentIds], + sessionIds: [recap.sessionId], + userId, + }); + + const updatedRecap = mockDb.recaps.find((item) => item.id === recapId); + + if (!updatedRecap) { + throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); + } + + return recapItemToDto(updatedRecap, userId); + }, + + async updateRecapThumbnail( + userId: string, + recapId: string, + input: { momentId: string }, + ) { + const recap = mockDb.recaps.find((item) => item.id === recapId && item.userId === userId); + + if (!recap) { + throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); + } + + if (!recap.sessionId) { + throw badRequest(ERROR_MESSAGES.RECAP_THUMBNAIL_LOG_REQUIRED); + } + + if (!getMockRecapMoments(recap).some((moment) => moment.id === input.momentId)) { + throw badRequest(ERROR_MESSAGES.RECAP_THUMBNAIL_MOMENT_NOT_FOUND); + } + + const thumbnailMoment = mockDb.momentLogs.find( + (moment) => + moment.id === input.momentId && + moment.sessionId === recap.sessionId, + ); + + if (!thumbnailMoment) { + throw badRequest(ERROR_MESSAGES.RECAP_THUMBNAIL_MOMENT_NOT_FOUND); + } + + recap.thumbnailMomentId = thumbnailMoment.id; + recap.backgroundImageUrl = thumbnailMoment.photoUrl; - return recapItemToDto(recap); + return recapItemToDto(recap, userId); }, async createRecapShareEvent(userId: string, recapId: string, input: { diff --git a/src/services/reverse-geocoding.service.ts b/src/services/reverse-geocoding.service.ts new file mode 100644 index 0000000..eb109f2 --- /dev/null +++ b/src/services/reverse-geocoding.service.ts @@ -0,0 +1,209 @@ +import { env } from '../config/env.js'; + +type AddressDetails = Record; + +type NominatimReverseResponse = { + address?: AddressDetails; + display_name?: unknown; + name?: unknown; + namedetails?: AddressDetails; + place_id?: unknown; +}; + +export type ReverseGeocodedPlace = { + address?: string; + attribution: string; + category: string; + id: string; + location: { + lat: number; + lng: number; + }; + source: 'reverse-geocode'; + title: string; +}; + +type CacheEntry = { + expiresAt: number; + value: ReverseGeocodedPlace | null; +}; + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const FAILURE_CACHE_TTL_MS = 5 * 60 * 1000; +const MIN_REQUEST_INTERVAL_MS = 1_000; +const REQUEST_TIMEOUT_MS = 5_000; +const OSM_ATTRIBUTION = '© OpenStreetMap contributors'; +const cache = new Map(); +const pendingRequests = new Map>(); + +let lastRequestStartedAt = 0; +let requestQueue: Promise = Promise.resolve(); + +function asNonEmptyString(value: unknown) { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function firstString(source: AddressDetails | undefined, keys: string[]) { + return keys.map((key) => asNonEmptyString(source?.[key])).find(Boolean); +} + +function createCacheKey(lat: number, lng: number) { + return `${lat.toFixed(4)}:${lng.toFixed(4)}`; +} + +function wait(milliseconds: number) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function scheduleRequest(request: () => Promise) { + let resolveResult: (value: T | PromiseLike) => void; + let rejectResult: (reason?: unknown) => void; + const result = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + + requestQueue = requestQueue + .catch(() => undefined) + .then(async () => { + const waitTime = Math.max( + MIN_REQUEST_INTERVAL_MS - (Date.now() - lastRequestStartedAt), + 0, + ); + + if (waitTime > 0) { + await wait(waitTime); + } + + lastRequestStartedAt = Date.now(); + + try { + resolveResult(await request()); + } catch (error) { + rejectResult(error); + } + }); + + return result; +} + +function normalizeReverseResult( + data: NominatimReverseResponse, + location: { lat: number; lng: number }, +): ReverseGeocodedPlace | null { + const address = data.address; + const koreanName = asNonEmptyString(data.namedetails?.['name:ko']); + const title = + koreanName ?? + firstString(address, [ + 'neighbourhood', + 'quarter', + 'suburb', + 'borough', + 'city_district', + 'village', + 'town', + 'city', + 'municipality', + 'county', + 'state', + 'country', + ]) ?? + asNonEmptyString(data.name); + + if (!title) { + return null; + } + + const addressText = asNonEmptyString(data.display_name); + const placeId = asNonEmptyString(data.place_id) ?? createCacheKey(location.lat, location.lng); + + return { + address: addressText, + attribution: OSM_ATTRIBUTION, + category: '현재 지역', + id: `reverse-${placeId}`, + location, + source: 'reverse-geocode', + title, + }; +} + +async function fetchReverseGeocodedPlace(location: { + lat: number; + lng: number; +}) { + const endpoint = `${env.REVERSE_GEOCODING_BASE_URL.replace(/\/$/, '')}/reverse`; + const query = new URLSearchParams({ + 'accept-language': 'ko,en', + addressdetails: '1', + format: 'jsonv2', + lat: String(location.lat), + layer: 'address', + lon: String(location.lng), + namedetails: '1', + zoom: '14', + }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(`${endpoint}?${query}`, { + headers: { + Accept: 'application/json', + 'Accept-Language': 'ko,en;q=0.8', + 'User-Agent': env.REVERSE_GEOCODING_USER_AGENT, + }, + signal: controller.signal, + }); + + if (!response.ok) { + return null; + } + + return normalizeReverseResult( + (await response.json()) as NominatimReverseResponse, + location, + ); + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +export async function reverseGeocodeLocation(location: { + lat: number; + lng: number; +}) { + const cacheKey = createCacheKey(location.lat, location.lng); + const cached = cache.get(cacheKey); + + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + const pending = pendingRequests.get(cacheKey); + + if (pending) { + return pending; + } + + const request = scheduleRequest(() => fetchReverseGeocodedPlace(location)) + .then((value) => { + cache.set(cacheKey, { + expiresAt: + Date.now() + (value ? CACHE_TTL_MS : FAILURE_CACHE_TTL_MS), + value, + }); + return value; + }) + .finally(() => { + pendingRequests.delete(cacheKey); + }); + + pendingRequests.set(cacheKey, request); + return request; +} diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index 84a9f85..bc9b682 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -28,6 +28,7 @@ import { prisma } from '../config/prisma.js'; import { getLimit, paginateByCursor } from '../utils/pagination.js'; import { createPublicId } from '../utils/tokens.js'; import { badRequest, forbidden, notFound } from '../utils/http-error.js'; +import { reverseGeocodeLocation } from './reverse-geocoding.service.js'; type MaybeUser = { id: string } | undefined; @@ -43,6 +44,8 @@ type TrackDto = { isSaved?: boolean; }; +const NO_MUSIC_TRACK_ID = 'soundlog-no-music'; + type RecommendationContext = Record; type CommunityVisibility = 'companions' | 'nearby' | 'private'; type RecapListScope = 'all' | 'mine' | 'others'; @@ -91,6 +94,19 @@ type MlRecommendationResponse = { tracks?: unknown; }; +type MlPlaylistDto = { + backgroundImageUrl?: string; + context: RecommendationContext; + coverImageUrl?: string; + durationText: string; + id: string; + placeName?: string; + reason: string; + regionName: string; + trackCount: number; + tracks: TrackDto[]; +}; + const TRAVEL_MATE_REQUEST_COOLDOWN_MS = 24 * 60 * 60 * 1000; const CLOSED_TRAVEL_MATE_REQUEST_STATUSES = ['cancelled', 'declined', 'expired']; @@ -117,9 +133,11 @@ type MomentLogUpdateInput = { placeId?: string | null; placeName?: string | null; sessionId?: string | null; + templateId?: string; trackId?: string; trackTitle?: string; travelMode?: string | null; + visibility?: RecapVisibility; }; type RecapWithMarkerRelations = Recap & { @@ -855,7 +873,9 @@ function normalizeMlTracks(rawTracks: unknown): TrackDto[] { }); } -async function fetchMlRecommendationPlaylist(input: ContextualPlaylistInput) { +async function fetchMlRecommendationPlaylist( + input: ContextualPlaylistInput, +): Promise { const location = input.location; if (!location) { @@ -918,7 +938,7 @@ async function fetchMlRecommendationPlaylist(input: ContextualPlaylistInput) { y: location.lat, }, tracks, - }); + }) as MlPlaylistDto; } catch { return undefined; } finally { @@ -926,6 +946,106 @@ async function fetchMlRecommendationPlaylist(input: ContextualPlaylistInput) { } } +async function persistMlRecommendationPlaylist(playlist: MlPlaylistDto) { + const trackIds = playlist.tracks.map((track) => track.id); + + await prisma.$transaction(async (transaction) => { + await transaction.playlist.upsert({ + where: { id: playlist.id }, + update: { + backgroundImageUrl: playlist.backgroundImageUrl, + context: toInputJson(playlist.context), + coverImageUrl: playlist.coverImageUrl, + durationText: playlist.durationText, + placeName: playlist.placeName, + reason: playlist.reason, + regionName: playlist.regionName, + source: 'ml-recommendation', + trackCount: playlist.trackCount, + }, + create: { + backgroundImageUrl: playlist.backgroundImageUrl, + context: toInputJson(playlist.context), + coverImageUrl: playlist.coverImageUrl, + durationText: playlist.durationText, + id: playlist.id, + placeName: playlist.placeName, + reason: playlist.reason, + regionName: playlist.regionName, + source: 'ml-recommendation', + trackCount: playlist.trackCount, + }, + }); + + for (const track of playlist.tracks) { + await transaction.track.upsert({ + where: { id: track.id }, + update: { + albumImageUrl: track.albumImageUrl, + artist: track.artist, + externalUrl: track.externalUrl, + fallbackColor: track.fallbackColor, + platformUrls: track.platformUrls ? toInputJson(track.platformUrls) : Prisma.JsonNull, + title: track.title, + }, + create: { + albumImageUrl: track.albumImageUrl, + artist: track.artist, + externalUrl: track.externalUrl, + fallbackColor: track.fallbackColor, + id: track.id, + platformUrls: track.platformUrls ? toInputJson(track.platformUrls) : undefined, + title: track.title, + }, + }); + } + + await transaction.playlistTrack.deleteMany({ + where: { + playlistId: playlist.id, + trackId: { notIn: trackIds }, + }, + }); + + for (const [position, track] of playlist.tracks.entries()) { + await transaction.playlistTrack.upsert({ + where: { + playlistId_trackId: { + playlistId: playlist.id, + trackId: track.id, + }, + }, + update: { position }, + create: { + playlistId: playlist.id, + position, + trackId: track.id, + }, + }); + } + }); +} + +async function withMlPlaylistTrackStates(playlist: MlPlaylistDto, userId?: string) { + const states = await getTrackStates( + userId, + playlist.tracks.map((track) => track.id), + ); + + return { + ...playlist, + tracks: playlist.tracks.map((track) => { + const state = states.get(track.id); + + return compact({ + ...track, + isLiked: state?.isLiked, + isSaved: state?.isSaved, + }) as TrackDto; + }), + }; +} + function trackToDto( track: Track, state?: Pick | null, @@ -1105,6 +1225,8 @@ function momentLogToDto(log: MomentLog) { placeId: log.placeId ?? undefined, placeName: log.placeName ?? undefined, note: log.note ?? undefined, + recapVisibility: log.visibility as RecapVisibility, + templateId: log.templateId, track: (log.trackSnapshot as TrackDto | null) ?? undefined, travelMode: log.travelMode ?? undefined, moodTags: log.moodTags, @@ -1127,42 +1249,131 @@ function musicLogItemFromMoment(log: MomentLog) { }); } -function recapItemToDto(recap: Recap & { representativeTrack: Track }) { +type StoredRecapMoment = { + artistName: string; + id: string; + imageUrl?: string; + location?: { lat: number; lng: number }; + placeName: string; + recordedAt: string; + templateId?: string; + track?: TrackDto; + trackTitle: string; + visibility?: RecapVisibility; +}; + +function recapMomentsToDto(recap: Recap) { + if (!Array.isArray(recap.moments)) { + return []; + } + + return (recap.moments as unknown[]).filter((value): value is StoredRecapMoment => { + if (!value || typeof value !== 'object') { + return false; + } + + const moment = value as Partial; + + return ( + typeof moment.id === 'string' && + typeof moment.placeName === 'string' && + typeof moment.recordedAt === 'string' && + typeof moment.trackTitle === 'string' && + typeof moment.artistName === 'string' + ); + }); +} + +function getVisibleRecapMoments(recap: Recap, viewerId?: string) { + const moments = recapMomentsToDto(recap); + + if (recap.userId === viewerId) { + return moments; + } + + return moments.filter( + (moment) => (moment.visibility ?? recap.visibility) === 'public', + ); +} + +function getRecapThumbnailMoment(recap: Recap, viewerId?: string) { + const visibleMoments = getVisibleRecapMoments(recap, viewerId); + + return ( + visibleMoments.find((moment) => moment.id === recap.thumbnailMomentId) ?? + visibleMoments[0] + ); +} + +function trackDtoFromRecapMoment(moment: StoredRecapMoment): TrackDto { + return ( + moment.track ?? { + artist: moment.artistName, + fallbackColor: '#252A38', + id: `recap-moment-track-${moment.id}`, + title: moment.trackTitle, + } + ); +} + +function recapItemToDto( + recap: Recap & { representativeTrack: Track }, + viewerId?: string, +) { + const isMine = recap.userId === viewerId; + const visibleMoments = getVisibleRecapMoments(recap, viewerId); + const publicRepresentative = isMine ? undefined : visibleMoments.at(-1); + const thumbnailMoment = getRecapThumbnailMoment(recap, viewerId); + return compact({ id: recap.id, - title: recap.title, - placeName: recap.placeName, - representativeTrack: trackToDto(recap.representativeTrack), + title: publicRepresentative && recap.sessionId + ? `${publicRepresentative.placeName} 여행 로그` + : recap.title, + placeName: publicRepresentative?.placeName ?? recap.placeName, + representativeTrack: publicRepresentative + ? trackDtoFromRecapMoment(publicRepresentative) + : trackToDto(recap.representativeTrack), createdAt: recap.createdAt.toISOString(), - momentCount: recap.momentCount ?? undefined, + momentCount: isMine ? recap.momentCount ?? undefined : visibleMoments.length, sessionId: recap.sessionId ?? undefined, + backgroundImageUrl: thumbnailMoment?.imageUrl ?? (isMine ? recap.backgroundImageUrl ?? undefined : undefined), + thumbnailMomentId: thumbnailMoment?.id, visibility: recap.visibility, }); } function recapShareToDto(recap: Recap & { representativeTrack: Track }, viewerId?: string) { const canViewRoutePoints = recap.userId === viewerId; + const visibleMoments = getVisibleRecapMoments(recap, viewerId); + const publicRepresentative = canViewRoutePoints ? undefined : visibleMoments.at(-1); + const thumbnailMoment = getRecapThumbnailMoment(recap, viewerId); return compact({ id: recap.id, - placeName: recap.placeName, - trackTitle: recap.representativeTrack.title, - artistName: recap.representativeTrack.artist, - backgroundImageUrl: recap.backgroundImageUrl ?? undefined, - discImageUrl: recap.discImageUrl ?? undefined, - moments: (recap.moments as Prisma.JsonArray | null) ?? undefined, - recordedAt: (recap.recordedAt ?? recap.createdAt).toISOString(), + isMine: canViewRoutePoints, + placeName: publicRepresentative?.placeName ?? recap.placeName, + trackTitle: publicRepresentative?.trackTitle ?? recap.representativeTrack.title, + artistName: publicRepresentative?.artistName ?? recap.representativeTrack.artist, + backgroundImageUrl: thumbnailMoment?.imageUrl ?? ( + canViewRoutePoints ? recap.backgroundImageUrl ?? undefined : undefined + ), + discImageUrl: publicRepresentative?.imageUrl ?? recap.discImageUrl ?? undefined, + moments: visibleMoments, + recordedAt: publicRepresentative?.recordedAt ?? + (recap.recordedAt ?? recap.createdAt).toISOString(), routePoints: canViewRoutePoints ? routePointsToDto(recap.routePoints) : undefined, + sessionId: recap.sessionId ?? undefined, shareImageUrl: recap.shareImageUrl ?? undefined, + templateId: recap.templateId, + thumbnailMomentId: thumbnailMoment?.id, visibility: recap.visibility, }); } -function getRecapMomentLocation(recap: Recap) { - const moments = Array.isArray(recap.moments) - ? (recap.moments as Array<{ location?: { lat?: unknown; lng?: unknown } }>) - : []; - const location = moments.find( +function getRecapMomentLocation(recap: Recap, viewerId?: string) { + const moments = getVisibleRecapMoments(recap, viewerId); + const location = [...moments].reverse().find( (moment) => typeof moment.location?.lat === 'number' && typeof moment.location?.lng === 'number', @@ -1178,15 +1389,20 @@ function getRecapMomentLocation(recap: Recap) { }; } -function getRecapLocation(recap: Recap) { - if (recap.lat !== null && recap.lng !== null) { +function getRecapLocation(recap: Recap, viewerId?: string) { + const canViewAllMoments = viewerId === undefined || recap.userId === viewerId; + + if (canViewAllMoments && recap.lat !== null && recap.lng !== null) { return { lat: recap.lat, lng: recap.lng, }; } - return getRecapMomentLocation(recap); + return getRecapMomentLocation( + recap, + canViewAllMoments ? recap.userId : viewerId, + ); } function assertPublicRecapHasLocation( @@ -1203,28 +1419,37 @@ function recapMapMarkerToDto( viewerId: string, origin?: { lat: number; lng: number }, ) { - const location = getRecapLocation(recap); + const location = getRecapLocation(recap, viewerId); if (!location) { return undefined; } const isMine = recap.userId === viewerId; + const publicRepresentative = isMine + ? undefined + : getVisibleRecapMoments(recap, viewerId).at(-1); + const thumbnailMoment = getRecapThumbnailMoment(recap, viewerId); + const publicTrack = publicRepresentative + ? trackDtoFromRecapMoment(publicRepresentative) + : undefined; return compact({ id: `marker-${recap.id}`, recapId: recap.id, - title: recap.title, - placeName: recap.placeName, + title: publicRepresentative && recap.sessionId + ? `${publicRepresentative.placeName} 여행 로그` + : recap.title, + placeName: publicRepresentative?.placeName ?? recap.placeName, ownerAlias: isMine ? '나' : recap.user.displayName ?? 'Soundlog 여행자', location, - trackTitle: recap.representativeTrack.title, - artistName: recap.representativeTrack.artist, - templateId: recap.templateId, + trackTitle: publicTrack?.title ?? recap.representativeTrack.title, + artistName: publicTrack?.artist ?? recap.representativeTrack.artist, + templateId: publicRepresentative?.templateId ?? recap.templateId, visibility: recap.visibility as RecapVisibility, distanceMeters: origin ? Math.round(distanceMeters(origin, location)) : undefined, - imageUrl: recap.backgroundImageUrl ?? undefined, - createdAt: recap.createdAt.toISOString(), + imageUrl: thumbnailMoment?.imageUrl ?? (isMine ? recap.backgroundImageUrl ?? undefined : undefined), + createdAt: publicRepresentative?.recordedAt ?? recap.createdAt.toISOString(), }); } @@ -1242,9 +1467,139 @@ function momentLogToRecapShareMoment(moment: MomentLog) { trackTitle: momentTrack?.title ?? '저장된 순간', artistName: momentTrack?.artist ?? '음악 없음', recordedAt: moment.createdAt.toISOString(), + templateId: moment.templateId, + track: momentTrack, + visibility: moment.visibility as RecapVisibility, + }); +} + +async function resolveAggregateTrack( + client: Prisma.TransactionClient, + moments: MomentLog[], +) { + const snapshot = [...moments] + .reverse() + .map((moment) => moment.trackSnapshot as TrackDto | null) + .find((track): track is TrackDto => Boolean(track?.id)); + + if (snapshot) { + return client.track.upsert({ + where: { id: snapshot.id }, + update: {}, + create: { + id: snapshot.id, + albumImageUrl: snapshot.albumImageUrl, + artist: snapshot.artist, + externalUrl: snapshot.externalUrl, + fallbackColor: snapshot.fallbackColor, + platformUrls: snapshot.platformUrls + ? toInputJson(snapshot.platformUrls) + : undefined, + title: snapshot.title, + }, + }); + } + + return client.track.upsert({ + where: { id: NO_MUSIC_TRACK_ID }, + update: {}, + create: { + id: NO_MUSIC_TRACK_ID, + artist: 'Soundlog', + fallbackColor: '#252A38', + title: '음악 없음', + }, }); } +async function refreshRecapAggregates( + client: Prisma.TransactionClient, + input: { + momentIds: string[]; + sessionIds: Array; + userId: string; + }, +) { + const sessionIds = new Set(input.sessionIds.filter((id): id is string => Boolean(id))); + const momentIds = new Set(input.momentIds); + const candidates = await client.recap.findMany({ + where: { + userId: input.userId, + OR: [ + { travelSessionId: { in: [...sessionIds] } }, + { sessionId: null, travelSessionId: null }, + ], + }, + }); + const affectedRecaps = candidates.filter( + (recap) => + (recap.travelSessionId + ? sessionIds.has(recap.travelSessionId) + : false) || + recapMomentsToDto(recap).some((moment) => momentIds.has(moment.id)), + ); + + for (const recap of affectedRecaps) { + const storedMomentIds = recapMomentsToDto(recap).map((moment) => moment.id); + const moments = await client.momentLog.findMany({ + where: recap.travelSessionId + ? { + sessionId: recap.travelSessionId, + userId: input.userId, + } + : { + id: { in: storedMomentIds }, + sessionId: null, + userId: input.userId, + }, + orderBy: { createdAt: 'asc' }, + }); + + if (moments.length === 0) { + await client.recap.delete({ where: { id: recap.id } }); + continue; + } + + const representativeMoment = moments.at(-1)!; + const thumbnailMoment = + moments.find((moment) => moment.id === recap.thumbnailMomentId) ?? moments[0]!; + const representativeTrack = await resolveAggregateTrack(client, moments); + const representativeLocation = + representativeMoment.lat !== null && representativeMoment.lng !== null + ? { lat: representativeMoment.lat, lng: representativeMoment.lng } + : moments.find((moment) => moment.lat !== null && moment.lng !== null); + const hasPublicLocatedMoment = moments.some( + (moment) => + moment.visibility === 'public' && + moment.lat !== null && + moment.lng !== null, + ); + + await client.recap.update({ + where: { id: recap.id }, + data: { + backgroundImageUrl: thumbnailMoment.photoUrl, + discImageUrl: representativeMoment.photoUrl, + lat: representativeLocation?.lat, + lng: representativeLocation?.lng, + momentCount: moments.length, + moments: moments.map(momentLogToRecapShareMoment) as Prisma.JsonArray, + placeName: representativeMoment.placeName ?? 'Soundlog', + recordedAt: representativeMoment.createdAt, + representativeTrackId: representativeTrack.id, + thumbnailMomentId: thumbnailMoment.id, + templateId: recap.travelSessionId + ? recap.templateId + : representativeMoment.templateId, + visibility: + recap.visibility === 'public' && !hasPublicLocatedMoment + ? 'private' + : recap.visibility, + }, + }); + } +} + function travelSessionToDto(session: TravelSession) { return compact({ id: session.id, @@ -1256,6 +1611,26 @@ function travelSessionToDto(session: TravelSession) { }); } +function normalizeMoodLabel(value?: string) { + const normalized = value?.trim(); + + if (!normalized) { + return undefined; + } + + return normalized === '청량한' ? '시원한' : normalized; +} + +function matchesMoodFilter(itemMoods: string[], moodFilter?: string) { + const normalizedFilter = normalizeMoodLabel(moodFilter); + + if (!normalizedFilter || normalizedFilter === '전체') { + return true; + } + + return itemMoods.some((mood) => normalizeMoodLabel(mood) === normalizedFilter); +} + function scoreMoodRecommendation( item: MoodRecommendation & { track: Track }, params: { @@ -1263,7 +1638,6 @@ function scoreMoodRecommendation( preferredGenres?: string[]; preferredMoods?: string[]; recommendationMode?: 'everyday' | 'travel'; - topFilter?: string; travelStyles?: string[]; }, ) { @@ -1271,23 +1645,11 @@ function scoreMoodRecommendation( const travelModeWeight = params.recommendationMode === 'travel' ? 2.4 : 1; const tasteWeight = params.recommendationMode === 'travel' ? 0.7 : 1.4; - if (params.topFilter && params.topFilter !== '전체' && item.moods.includes(params.topFilter)) { - score += 8; - } - - if ( - params.moodFilter && - params.moodFilter !== '전체' && - item.moods.includes(params.moodFilter) - ) { - score += 8; - } - score += (params.preferredGenres ?? []).filter((genre) => item.genres.includes(genre), ).length * 3 * tasteWeight; score += (params.preferredMoods ?? []).filter((mood) => - item.moods.includes(mood), + item.moods.some((itemMood) => normalizeMoodLabel(itemMood) === normalizeMoodLabel(mood)), ).length * 2 * tasteWeight; score += (params.travelStyles ?? []).filter((style) => item.travelStyles.includes(style), @@ -1380,6 +1742,20 @@ export const soundlogService = { return profileToDto(profile); }, + async deleteMyAccount(userId: string) { + const momentLogs = await prisma.momentLog.findMany({ + where: { userId }, + select: { photoUrl: true }, + }); + + await prisma.user.delete({ where: { id: userId } }); + await Promise.all( + momentLogs.map((momentLog) => deleteLocalUploadedFile(momentLog.photoUrl)), + ); + + return { deleted: true }; + }, + async migrateLocalData(_userId: string, input: { idempotencyKey: string; libraryTrackCount: number; @@ -1404,23 +1780,75 @@ export const soundlogService = { lng: number; radiusMeters?: number; }) { + const origin = { lat: params.lat, lng: params.lng }; + const radiusMeters = params.radiusMeters ?? 2000; + const limit = getLimit(params.limit, 10); const tourPlaces = await fetchTourApiPlaces(params); if (tourPlaces.length > 0) { - return tourPlaces.slice(0, getLimit(params.limit, 10)); + return tourPlaces + .flatMap((place) => { + if (!place.location) { + return []; + } + + const distance = Math.round(distanceMeters(origin, place.location)); + + return distance <= radiusMeters + ? [{ ...place, distanceMeters: distance }] + : []; + }) + .sort((first, second) => first.distanceMeters - second.distanceMeters) + .slice(0, limit); } - const isSouthernContext = params.lat < 36.5; + const latitudeDelta = radiusMeters / 111_320; + const longitudeScale = Math.max(Math.cos((params.lat * Math.PI) / 180), 0.01); + const longitudeDelta = radiusMeters / (111_320 * longitudeScale); const places = await prisma.place.findMany({ - orderBy: [{ distanceMeters: 'asc' }, { title: 'asc' }], + where: { + lat: { gte: params.lat - latitudeDelta, lte: params.lat + latitudeDelta }, + lng: { gte: params.lng - longitudeDelta, lte: params.lng + longitudeDelta }, + }, }); - const sorted = [...places].sort((first, second) => { - const firstScore = isSouthernContext && first.address?.startsWith('부산') ? -1 : 0; - const secondScore = isSouthernContext && second.address?.startsWith('부산') ? -1 : 0; - return firstScore - secondScore; + + return places + .flatMap((place) => { + if (place.lat === null || place.lng === null) { + return []; + } + + const distance = Math.round( + distanceMeters(origin, { lat: place.lat, lng: place.lng }), + ); + + return distance <= radiusMeters + ? [{ ...placeToDto(place), distanceMeters: distance }] + : []; + }) + .sort((first, second) => first.distanceMeters - second.distanceMeters) + .slice(0, limit); + }, + + async reverseGeocodeLocation(params: { lat: number; lng: number }) { + return reverseGeocodeLocation(params); + }, + + async searchPlaces(params: { limit?: number; query: string }) { + const query = params.query.trim(); + const places = await prisma.place.findMany({ + where: { + OR: [ + { title: { contains: query, mode: 'insensitive' } }, + { address: { contains: query, mode: 'insensitive' } }, + { category: { contains: query, mode: 'insensitive' } }, + ], + }, + orderBy: [{ title: 'asc' }], + take: getLimit(params.limit, 10), }); - return sorted.slice(0, getLimit(params.limit, 10)).map(placeToDto); + return places.map(placeToDto); }, async getFeaturedPlaylists( @@ -1435,6 +1863,12 @@ export const soundlogService = { ) { const playlists = await prisma.playlist.findMany({ orderBy: { updatedAt: 'desc' }, + where: { + OR: [ + { source: null }, + { source: { not: 'personalized' } }, + ], + }, }); const preferredId = params.recommendationMode === 'travel' && @@ -1476,7 +1910,6 @@ export const soundlogService = { preferredGenres?: string[]; preferredMoods?: string[]; recommendationMode?: 'everyday' | 'travel'; - topFilter?: string; travelStyles?: string[]; }, ) { @@ -1486,6 +1919,7 @@ export const soundlogService = { }); return recommendations + .filter((item) => matchesMoodFilter(item.moods, params.moodFilter)) .sort((first, second) => scoreMoodRecommendation(second, params) - scoreMoodRecommendation(first, params)) .slice(0, getLimit(params.limit)) .map((item) => @@ -1495,7 +1929,9 @@ export const soundlogService = { subtitle: item.subtitle ?? undefined, color: item.color, genres: item.genres, + imageUrl: item.imageUrl ?? undefined, moods: item.moods, + playlistId: item.playlistId ?? undefined, travelStyles: item.travelStyles, track: trackToDto(item.track), }), @@ -1523,7 +1959,8 @@ export const soundlogService = { const mlPlaylist = await fetchMlRecommendationPlaylist(input); if (mlPlaylist) { - return mlPlaylist; + await persistMlRecommendationPlaylist(mlPlaylist); + return withMlPlaylistTrackStates(mlPlaylist, userId); } const playlistId = await findDefaultPlaylist({ @@ -1562,7 +1999,8 @@ export const soundlogService = { const mlPlaylist = await fetchMlRecommendationPlaylist(input); if (mlPlaylist) { - return mlPlaylist; + await persistMlRecommendationPlaylist(mlPlaylist); + return withMlPlaylistTrackStates(mlPlaylist, userId); } const playlistId = await findDefaultPlaylist({ @@ -1753,15 +2191,24 @@ export const soundlogService = { placeName?: string; note?: string; sessionId?: string; + templateId?: string; trackId?: string; trackTitle?: string; travelMode?: string; + visibility?: RecapVisibility; }, idempotencyKey?: string, ) { return withIdempotency( { idempotencyKey, scope: 'moment-log.create', userId }, async () => { + const location = + input.lat !== undefined && input.lng !== undefined + ? { lat: input.lat, lng: input.lng } + : undefined; + + assertPublicRecapHasLocation(input.visibility, location); + const track = input.trackId ? await prisma.track.findUnique({ where: { id: input.trackId } }) : undefined; @@ -1769,34 +2216,46 @@ export const soundlogService = { ? normalizePublicUrl(env.UPLOAD_PUBLIC_BASE_URL, input.photoPath) : undefined; const id = createPublicId('moment'); - const log = await prisma.momentLog.create({ - data: { - id, + const log = await prisma.$transaction(async (transaction) => { + const created = await transaction.momentLog.create({ + data: { + id, + userId, + photoUrl, + createdAt: new Date(input.createdAt), + sessionId: input.sessionId, + lat: input.lat, + lng: input.lng, + placeCategory: input.placeCategory, + placeId: input.placeId, + placeName: input.placeName, + note: input.note, + templateId: input.templateId ?? 'album', + trackSnapshot: + track || input.trackTitle + ? { + id: track?.id ?? input.trackId ?? createPublicId('track'), + title: track?.title ?? input.trackTitle ?? '저장된 순간', + artist: track?.artist ?? input.artistName ?? '음악 없음', + fallbackColor: track?.fallbackColor, + platformUrls: track?.platformUrls, + } + : undefined, + travelMode: input.travelMode, + moodTags: input.moodTags, + source: 'camera', + syncStatus: 'synced', + visibility: input.visibility ?? 'private', + }, + }); + + await refreshRecapAggregates(transaction, { + momentIds: [created.id], + sessionIds: [created.sessionId], userId, - photoUrl, - createdAt: new Date(input.createdAt), - sessionId: input.sessionId, - lat: input.lat, - lng: input.lng, - placeCategory: input.placeCategory, - placeId: input.placeId, - placeName: input.placeName, - note: input.note, - trackSnapshot: - track || input.trackTitle - ? { - id: track?.id ?? input.trackId ?? createPublicId('track'), - title: track?.title ?? input.trackTitle ?? '저장된 순간', - artist: track?.artist ?? input.artistName ?? '음악 없음', - fallbackColor: track?.fallbackColor, - platformUrls: track?.platformUrls, - } - : undefined, - travelMode: input.travelMode, - moodTags: input.moodTags, - source: 'camera', - syncStatus: 'synced', - }, + }); + + return created; }); return momentLogToDto(log); @@ -1858,10 +2317,30 @@ export const soundlogService = { data.sessionId = input.sessionId ?? null; } + if (input.templateId) { + data.templateId = input.templateId; + } + if (hasOwn(input, 'travelMode')) { data.travelMode = input.travelMode ?? null; } + if (input.visibility) { + data.visibility = input.visibility; + } + + if (hasOwn(input, 'lat') || hasOwn(input, 'lng') || input.visibility) { + const nextLat = hasOwn(input, 'lat') ? input.lat : existing.lat; + const nextLng = hasOwn(input, 'lng') ? input.lng : existing.lng; + const nextVisibility = input.visibility ?? (existing.visibility as RecapVisibility); + const location = + nextLat !== null && nextLat !== undefined && nextLng !== null && nextLng !== undefined + ? { lat: nextLat, lng: nextLng } + : undefined; + + assertPublicRecapHasLocation(nextVisibility, location); + } + const shouldUpdateTrackSnapshot = hasOwn(input, 'trackId') || hasOwn(input, 'trackTitle') || @@ -1884,9 +2363,19 @@ export const soundlogService = { return momentLogToDto(existing); } - const updated = await prisma.momentLog.update({ - where: { id: existing.id }, - data, + const updated = await prisma.$transaction(async (transaction) => { + const nextMoment = await transaction.momentLog.update({ + where: { id: existing.id }, + data, + }); + + await refreshRecapAggregates(transaction, { + momentIds: [existing.id], + sessionIds: [existing.sessionId, nextMoment.sessionId], + userId, + }); + + return nextMoment; }); return momentLogToDto(updated); @@ -1906,9 +2395,19 @@ export const soundlogService = { throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); } - const updated = await prisma.momentLog.update({ - where: { id: existing.id }, - data: { photoUrl: nextPhotoUrl }, + const updated = await prisma.$transaction(async (transaction) => { + const nextMoment = await transaction.momentLog.update({ + where: { id: existing.id }, + data: { photoUrl: nextPhotoUrl }, + }); + + await refreshRecapAggregates(transaction, { + momentIds: [existing.id], + sessionIds: [existing.sessionId], + userId, + }); + + return nextMoment; }); await deleteLocalUploadedFile(existing.photoUrl); @@ -1932,9 +2431,19 @@ export const soundlogService = { return momentLogToDto(existing); } - const updated = await prisma.momentLog.update({ - where: { id: existing.id }, - data: { photoUrl: null }, + const updated = await prisma.$transaction(async (transaction) => { + const nextMoment = await transaction.momentLog.update({ + where: { id: existing.id }, + data: { photoUrl: null }, + }); + + await refreshRecapAggregates(transaction, { + momentIds: [existing.id], + sessionIds: [existing.sessionId], + userId, + }); + + return nextMoment; }); await deleteLocalUploadedFile(existing.photoUrl); @@ -1948,20 +2457,25 @@ export const soundlogService = { id: momentLogId, userId, }, - select: { id: true, photoUrl: true }, + select: { id: true, photoUrl: true, sessionId: true }, }); if (!existing) { throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); } - await prisma.$transaction([ - prisma.travelRoomMoment.updateMany({ + await prisma.$transaction(async (transaction) => { + await transaction.travelRoomMoment.updateMany({ where: { momentLogId: existing.id }, data: { momentLogId: null }, - }), - prisma.momentLog.delete({ where: { id: existing.id } }), - ]); + }); + await transaction.momentLog.delete({ where: { id: existing.id } }); + await refreshRecapAggregates(transaction, { + momentIds: [existing.id], + sessionIds: [existing.sessionId], + userId, + }); + }); await deleteLocalUploadedFile(existing.photoUrl); }, @@ -2409,7 +2923,7 @@ export const soundlogService = { }); return compact({ - ...recapItemToDto(recap), + ...recapItemToDto(recap, userId), roomId, templateId: input.templateId, }); @@ -2895,7 +3409,7 @@ export const soundlogService = { scope?: RecapListScope; }) { const scope = params.scope ?? 'mine'; - const where: Prisma.RecapWhereInput = + const scopeWhere: Prisma.RecapWhereInput = scope === 'mine' ? { userId } : scope === 'others' @@ -2907,15 +3421,20 @@ export const soundlogService = { ], }; const recaps = await prisma.recap.findMany({ - where, + where: { + AND: [{ sessionId: { not: null } }, scopeWhere], + }, include: { representativeTrack: true }, orderBy: { createdAt: 'desc' }, }); + const visibleRecaps = recaps.filter( + (recap) => recap.userId === userId || getVisibleRecapMoments(recap, userId).length > 0, + ); const limit = getLimit(params.limit); - const page = paginateByCursor(recaps, limit, params.cursor); + const page = paginateByCursor(visibleRecaps, limit, params.cursor); return { - data: page.items.map(recapItemToDto), + data: page.items.map((recap) => recapItemToDto(recap, userId)), page: { limit, nextCursor: page.nextCursor, @@ -2933,7 +3452,10 @@ export const soundlogService = { }, ) { const scope = params.scope ?? 'public'; - const origin = hasGeoPoint(params) ? { lat: params.lat!, lng: params.lng! } : undefined; + const origin = + scope === 'public' && hasGeoPoint(params) + ? { lat: params.lat!, lng: params.lng! } + : undefined; const radiusMeters = RECAP_DISCOVERY_RADIUS_METERS; const recaps = await prisma.recap.findMany({ where: scope === 'mine' @@ -2955,10 +3477,15 @@ export const soundlogService = { .flatMap((recap) => { const marker = recapMapMarkerToDto(recap, userId, origin); - return marker ? [marker] : []; + return marker && (recap.userId === userId || getVisibleRecapMoments(recap, userId).length) + ? [marker] + : []; }) .filter((marker) => - origin ? typeof marker.distanceMeters === 'number' && marker.distanceMeters <= radiusMeters : true, + origin + ? typeof marker.distanceMeters === 'number' && + marker.distanceMeters <= radiusMeters + : true, ); }, @@ -2978,6 +3505,10 @@ export const soundlogService = { return withIdempotency( { idempotencyKey, scope: 'recap.create', userId }, async () => { + if (!input.sessionId && !input.momentLogIds?.length) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_REQUIRES_CAPTURE); + } + const moments = await prisma.momentLog.findMany({ where: { userId, @@ -2986,90 +3517,201 @@ export const soundlogService = { }, orderBy: { createdAt: 'asc' }, }); - const travelSession = input.sessionId - ? await prisma.travelSession.findFirst({ - where: { - id: input.sessionId, - userId, - }, + const requestedMomentIds = Array.from(new Set(input.momentLogIds ?? [])); + + if (requestedMomentIds.length > 0 && moments.length !== requestedMomentIds.length) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_CAPTURE_MISMATCH); + } + + if ( + !input.sessionId && + (requestedMomentIds.length !== 1 || moments.some((moment) => moment.sessionId)) + ) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_CAPTURE_MISMATCH); + } + + if (moments.length === 0) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_REQUIRES_CAPTURE); + } + + let travelSession = input.sessionId + ? await prisma.travelSession.findUnique({ + where: { id: input.sessionId }, }) : undefined; + + if (travelSession && travelSession.userId !== userId) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_CAPTURE_MISMATCH); + } + + if (input.sessionId && !travelSession) { + const recoveredRoutePoints = normalizeRoutePoints(input.routePoints); + const recordedAt = moments.at(-1)?.createdAt ?? new Date(); + const firstRoutePoint = input.routePoints?.[0]; + const lastRoutePoint = input.routePoints?.at(-1); + + travelSession = await prisma.travelSession.create({ + data: { + id: input.sessionId, + userId, + status: 'ended', + startedAt: firstRoutePoint + ? new Date(firstRoutePoint.recordedAt) + : moments[0]?.createdAt ?? recordedAt, + endedAt: lastRoutePoint + ? new Date(lastRoutePoint.recordedAt) + : recordedAt, + routePoints: recoveredRoutePoints, + }, + }); + } + + if (input.sessionId) { + const existingLog = await prisma.recap.findUnique({ + where: { travelSessionId: input.sessionId }, + include: { representativeTrack: true }, + }); + + if (existingLog) { + if (existingLog.userId !== userId) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_CAPTURE_MISMATCH); + } + + return recapItemToDto(existingLog, userId); + } + } const sessionRoutePoints = routePointsToDto(travelSession?.routePoints); const routePoints = normalizeRoutePoints(input.routePoints) ?? normalizeRoutePoints(sessionRoutePoints); + const momentTrackSnapshots = [...moments] + .reverse() + .map((moment) => moment.trackSnapshot as TrackDto | null) + .filter((snapshot): snapshot is TrackDto => Boolean(snapshot?.id)); const candidateTrackIds = input.representativeTrackId ? [input.representativeTrackId] - : Array.from( - new Set( - [...moments] - .reverse() - .map((moment) => (moment.trackSnapshot as TrackDto | null)?.id) - .filter((trackId): trackId is string => Boolean(trackId)), - ), - ); + : Array.from(new Set(momentTrackSnapshots.map((snapshot) => snapshot.id))); const candidateTracks = candidateTrackIds.length ? await prisma.track.findMany({ where: { id: { in: candidateTrackIds } } }) : []; const representativeTrackId = - input.representativeTrackId ?? - candidateTrackIds.find((trackId) => - candidateTracks.some((candidateTrack) => candidateTrack.id === trackId), - ) ?? - 'seoul-city'; - const track = + input.representativeTrackId ?? candidateTrackIds[0] ?? NO_MUSIC_TRACK_ID; + const representativeTrackSnapshot = momentTrackSnapshots.find( + (snapshot) => snapshot.id === representativeTrackId, + ); + let track = candidateTracks.find((candidateTrack) => candidateTrack.id === representativeTrackId) ?? - (await prisma.track.findUnique({ - where: { id: representativeTrackId }, - })); + (await prisma.track.findUnique({ where: { id: representativeTrackId } })); + + if (!track && representativeTrackSnapshot) { + track = await prisma.track.upsert({ + where: { id: representativeTrackSnapshot.id }, + update: {}, + create: { + id: representativeTrackSnapshot.id, + albumImageUrl: representativeTrackSnapshot.albumImageUrl, + artist: representativeTrackSnapshot.artist, + externalUrl: representativeTrackSnapshot.externalUrl, + fallbackColor: representativeTrackSnapshot.fallbackColor, + platformUrls: representativeTrackSnapshot.platformUrls + ? toInputJson(representativeTrackSnapshot.platformUrls) + : undefined, + title: representativeTrackSnapshot.title, + }, + }); + } + + if (!track && representativeTrackId === NO_MUSIC_TRACK_ID) { + track = await prisma.track.upsert({ + where: { id: NO_MUSIC_TRACK_ID }, + update: {}, + create: { + id: NO_MUSIC_TRACK_ID, + artist: 'Soundlog', + fallbackColor: '#252A38', + title: '음악 없음', + }, + }); + } if (!track) { throw notFound(ERROR_MESSAGES.REPRESENTATIVE_TRACK_NOT_FOUND); } - const firstMoment = moments[0]; - const firstMomentLocation = - firstMoment?.lat !== null && - firstMoment?.lat !== undefined && - firstMoment?.lng !== null && - firstMoment?.lng !== undefined + const representativeMoment = moments.at(-1)!; + const thumbnailMoment = moments[0]!; + const representativeMomentLocation = + representativeMoment.lat !== null && + representativeMoment.lng !== null ? { - lat: firstMoment.lat, - lng: firstMoment.lng, + lat: representativeMoment.lat, + lng: representativeMoment.lng, } : undefined; const firstRoutePoint = input.routePoints?.[0] ?? sessionRoutePoints?.[0]; - const recapLocation = firstMomentLocation ?? ( + const recapLocation = representativeMomentLocation ?? ( firstRoutePoint ? { lat: firstRoutePoint.lat, lng: firstRoutePoint.lng } : undefined ); - assertPublicRecapHasLocation(input.visibility, recapLocation); + const hasPublicLocatedMoment = moments.some( + (moment) => + moment.visibility === 'public' && + moment.lat !== null && + moment.lng !== null, + ); - const recap = await prisma.recap.create({ - data: { + if (input.visibility === 'public' && !hasPublicLocatedMoment) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_PUBLIC_CAPTURE_REQUIRED); + } + + let recap: Recap & { representativeTrack: Track }; + + try { + recap = await prisma.recap.create({ + data: { id: createPublicId('recap'), userId, - title: input.title ?? `${firstMoment?.placeName ?? '여행'}의 사운드`, - placeName: firstMoment?.placeName ?? 'Soundlog', + title: input.title ?? `${representativeMoment.placeName ?? '여행'}의 사운드`, + placeName: representativeMoment.placeName ?? 'Soundlog', representativeTrackId: track.id, momentCount: moments.length, sessionId: input.sessionId, - backgroundImageUrl: firstMoment?.photoUrl, - discImageUrl: firstMoment?.photoUrl, - recordedAt: firstMoment?.createdAt ?? new Date(), + travelSessionId: input.sessionId, + backgroundImageUrl: thumbnailMoment.photoUrl, + discImageUrl: representativeMoment.photoUrl, + recordedAt: representativeMoment.createdAt, moments: moments.map(momentLogToRecapShareMoment) as Prisma.JsonArray, routePoints, templateId: input.templateId, + thumbnailMomentId: thumbnailMoment.id, visibility: input.visibility ?? 'private', lat: recapLocation?.lat, lng: recapLocation?.lng, - }, - include: { representativeTrack: true }, - }); + }, + include: { representativeTrack: true }, + }); + } catch (error) { + if ( + input.sessionId && + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + const concurrentLog = await prisma.recap.findUnique({ + where: { travelSessionId: input.sessionId }, + include: { representativeTrack: true }, + }); + + if (concurrentLog?.userId === userId) { + return recapItemToDto(concurrentLog, userId); + } + } + + throw error; + } - return recapItemToDto(recap); + return recapItemToDto(recap, userId); }, ); }, @@ -3090,6 +3732,10 @@ export const soundlogService = { throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); } + if (recap.userId !== userId && getVisibleRecapMoments(recap, userId).length === 0) { + throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); + } + return recapShareToDto(recap, userId); }, @@ -3109,15 +3755,115 @@ export const soundlogService = { throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); } - assertPublicRecapHasLocation(input.visibility, getRecapLocation(recap)); + const memberMomentIds = recapMomentsToDto(recap).map((moment) => moment.id); + + if (input.visibility === 'public' && recap.sessionId) { + const hasPublicLocatedMoment = recapMomentsToDto(recap).some( + (moment) => + moment.visibility === 'public' && + typeof moment.location?.lat === 'number' && + typeof moment.location?.lng === 'number', + ); + + if (!hasPublicLocatedMoment) { + throw badRequest(ERROR_MESSAGES.RECAP_LOG_PUBLIC_CAPTURE_REQUIRED); + } + } + + const updatedRecap = await prisma.$transaction(async (transaction) => { + if (!recap.sessionId) { + const moments = await transaction.momentLog.findMany({ + where: { + id: { in: memberMomentIds }, + userId, + }, + }); + const locatedMoment = moments.find( + (moment) => moment.lat !== null && moment.lng !== null, + ); + + assertPublicRecapHasLocation( + input.visibility, + locatedMoment && locatedMoment.lat !== null && locatedMoment.lng !== null + ? { lat: locatedMoment.lat, lng: locatedMoment.lng } + : undefined, + ); + + await transaction.momentLog.updateMany({ + where: { + id: { in: memberMomentIds }, + userId, + }, + data: { visibility: input.visibility }, + }); + } + + await transaction.recap.update({ + where: { id: recapId }, + data: { visibility: input.visibility }, + }); + + await refreshRecapAggregates(transaction, { + momentIds: memberMomentIds, + sessionIds: [recap.sessionId], + userId, + }); + + return transaction.recap.findUniqueOrThrow({ + where: { id: recapId }, + include: { representativeTrack: true }, + }); + }); + + return recapItemToDto(updatedRecap, userId); + }, + + async updateRecapThumbnail( + userId: string, + recapId: string, + input: { momentId: string }, + ) { + const recap = await prisma.recap.findFirst({ + where: { + id: recapId, + userId, + }, + }); + + if (!recap) { + throw notFound(ERROR_MESSAGES.RECAP_NOT_FOUND); + } + + if (!recap.sessionId) { + throw badRequest(ERROR_MESSAGES.RECAP_THUMBNAIL_LOG_REQUIRED); + } + + if (!recapMomentsToDto(recap).some((moment) => moment.id === input.momentId)) { + throw badRequest(ERROR_MESSAGES.RECAP_THUMBNAIL_MOMENT_NOT_FOUND); + } + + const thumbnailMoment = await prisma.momentLog.findFirst({ + where: { + id: input.momentId, + sessionId: recap.sessionId, + userId, + }, + }); + + if (!thumbnailMoment) { + throw badRequest(ERROR_MESSAGES.RECAP_THUMBNAIL_MOMENT_NOT_FOUND); + } const updatedRecap = await prisma.recap.update({ where: { id: recapId }, - data: { visibility: input.visibility }, + data: { + backgroundImageUrl: thumbnailMoment.photoUrl, + thumbnailMomentId: thumbnailMoment.id, + }, include: { representativeTrack: true }, }); - return recapItemToDto(updatedRecap); + return recapItemToDto(updatedRecap, userId); }, async createRecapShareEvent( diff --git a/src/validators/api.validators.ts b/src/validators/api.validators.ts index a611b1c..e8547a9 100644 --- a/src/validators/api.validators.ts +++ b/src/validators/api.validators.ts @@ -92,6 +92,8 @@ const routePointSchema = geoPointSchema.extend({ }); const routePointsSchema = z.array(routePointSchema).max(500); +const recapTemplateSchema = z.enum(['album', 'film', 'lp', 'map']); +const recapVisibilitySchema = z.enum(['private', 'public']); const optionalLatQuery = z.coerce.number().min(-90).max(90).optional(); const optionalLngQuery = z.coerce.number().min(-180).max(180).optional(); @@ -103,7 +105,6 @@ const recommendationContextSchema = z placeId: z.string().optional(), placeName: z.string().optional(), recommendationMode: recommendationModeSchema.optional(), - topFilter: z.string().optional(), travelMode: travelModeSchema.optional(), }) .default({}); @@ -157,6 +158,14 @@ export const meValidators = { }; export const tourValidators = { + reverseGeocodeQuery: z.object({ + lat: queryNumber.min(-90).max(90), + lng: queryNumber.min(-180).max(180), + }), + searchQuery: z.object({ + limit, + query: z.string().trim().min(1).max(80), + }), nearbyQuery: z.object({ contentTypes: z.string().optional(), lat: queryNumber.min(-90).max(90), @@ -182,7 +191,6 @@ export const homeValidators = { preferredGenres: optionalCsvArray, preferredMoods: optionalCsvArray, recommendationMode: recommendationModeSchema.optional().default('everyday'), - topFilter: z.string().default('전체'), travelMode: travelModeSchema.optional(), travelStyles: optionalCsvArray, }), @@ -257,9 +265,11 @@ export const momentLogValidators = { placeId: z.string().optional(), placeName: z.string().optional(), sessionId: z.string().optional(), + templateId: recapTemplateSchema.optional().default('album'), trackId: z.string().optional(), trackTitle: z.string().optional(), travelMode: travelModeSchema.optional(), + visibility: recapVisibilitySchema.optional().default('private'), }), updateBody: z.object({ artistName: z.string().optional(), @@ -272,9 +282,11 @@ export const momentLogValidators = { placeId: z.union([z.string(), z.null()]).optional(), placeName: z.union([z.string(), z.null()]).optional(), sessionId: z.union([z.string(), z.null()]).optional(), + templateId: recapTemplateSchema.optional(), trackId: z.string().optional(), trackTitle: z.string().optional(), travelMode: z.union([travelModeSchema, z.null()]).optional(), + visibility: recapVisibilitySchema.optional(), }), }; @@ -350,15 +362,18 @@ export const recapValidators = { representativeTrackId: z.string().optional(), routePoints: routePointsSchema.optional(), sessionId: z.string().optional(), - templateId: z.enum(['album', 'film', 'lp', 'map', 'video']), + templateId: recapTemplateSchema, title: z.string().optional(), - visibility: z.enum(['private', 'public']).optional().default('private'), + visibility: recapVisibilitySchema.optional().default('private'), }), recapParams: z.object({ recapId: z.string().min(1), }), visibilityBody: z.object({ - visibility: z.enum(['private', 'public']), + visibility: recapVisibilitySchema, + }), + thumbnailBody: z.object({ + momentId: z.string().min(1), }), shareEventBody: z.object({ createdAt: z.string().datetime(), diff --git a/tests/api.test.ts b/tests/api.test.ts index dc91c47..aa23517 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,6 +1,6 @@ import bcrypt from 'bcrypt'; import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { createApp } from '../src/app.js'; import { prisma } from '../src/config/prisma.js'; @@ -72,6 +72,7 @@ function findSecretLeaks(value: unknown, secret: string, path = '$'): string[] { } async function createTestMomentLog(input: { + artistName?: string; authHeader: string; filename: string; lat?: number; @@ -79,7 +80,9 @@ async function createTestMomentLog(input: { note?: string; placeName: string; sessionId?: string; - trackId?: string; + trackId?: string | null; + trackTitle?: string; + visibility?: 'private' | 'public'; }) { const requestBuilder = request(app) .post('/v1/moment-logs') @@ -89,7 +92,19 @@ async function createTestMomentLog(input: { .field('note', input.note ?? '') .field('placeName', input.placeName) .field('sessionId', input.sessionId ?? '') - .field('trackId', input.trackId ?? 'seoul-city'); + .field('visibility', input.visibility ?? 'private'); + + if (input.trackId !== null) { + requestBuilder.field('trackId', input.trackId ?? 'seoul-city'); + } + + if (input.artistName) { + requestBuilder.field('artistName', input.artistName); + } + + if (input.trackTitle) { + requestBuilder.field('trackTitle', input.trackTitle); + } if (input.lat !== undefined) { requestBuilder.field('lat', String(input.lat)); @@ -109,6 +124,16 @@ async function createTestMomentLog(input: { return response.body.data; } +async function createTestTravelSession(authHeader: string) { + const response = await request(app) + .post('/v1/travel-sessions') + .set('Authorization', authHeader) + .send({ startedAt: new Date().toISOString(), travelMode: 'walk' }); + + expect(response.status).toBe(201); + return response.body.data.id as string; +} + describe('Soundlog API', () => { let accessToken: string; let authHeader: string; @@ -285,6 +310,38 @@ describe('Soundlog API', () => { expect(logout.body.data.accepted).toBe(true); }); + it('deletes the authenticated account and invalidates its credentials', async () => { + const email = `delete-${Date.now()}@soundlog.test`; + const password = 'delete-account-password'; + const register = await request(app).post('/v1/auth/register').send({ + displayName: 'Delete Account User', + email, + password, + }); + const accessToken = register.body.data.accessToken as string; + + expect(register.status).toBe(201); + + const deleted = await request(app) + .delete('/v1/me') + .set('Authorization', `Bearer ${accessToken}`); + + expect(deleted.status).toBe(200); + expect(deleted.body.data).toEqual({ deleted: true }); + + const meAfterDeletion = await request(app) + .get('/v1/me') + .set('Authorization', `Bearer ${accessToken}`); + expect(meAfterDeletion.status).toBe(401); + + const loginAfterDeletion = await request(app).post('/v1/auth/login').send({ + email, + password, + }); + expect(loginAfterDeletion.status).toBe(401); + expect(await getStoredPasswordHash(email)).toBeUndefined(); + }); + it('handles profile APIs', async () => { const profile = await request(app) .get('/v1/me/profile') @@ -306,6 +363,19 @@ describe('Soundlog API', () => { }); it('returns tour and home data', async () => { + const searchedPlaces = await request(app) + .get('/v1/tour/places') + .set('Authorization', authHeader) + .query({ query: '광안리', limit: 5 }); + expect(searchedPlaces.status).toBe(200); + expect(searchedPlaces.body.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: expect.stringContaining('광안리'), + }), + ]), + ); + const tour = await request(app) .get('/v1/tour/nearby-places') .set('Authorization', authHeader) @@ -318,12 +388,85 @@ describe('Soundlog API', () => { expect(tour.body.data[0].id).toContain('seed-'); expect(tour.body.data[0].source).toBe('seed'); + const distantTour = await request(app) + .get('/v1/tour/nearby-places') + .set('Authorization', authHeader) + .query({ + lat: 37.785834, + lng: -122.406417, + radiusMeters: 2000, + }); + expect(distantTour.status).toBe(200); + expect(distantTour.body.data).toEqual([]); + + const reverseGeocodeFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + address: { + city: '샌프란시스코', + country: '미국', + state: '캘리포니아주', + }, + display_name: '샌프란시스코, 캘리포니아주, 미국', + namedetails: { + 'name:ko': '샌프란시스코', + }, + place_id: 12345, + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ), + ); + + try { + const reverseGeocoded = await request(app) + .get('/v1/tour/reverse-geocode') + .set('Authorization', authHeader) + .query({ lat: 37.785834, lng: -122.406417 }); + + expect(reverseGeocoded.status).toBe(200); + expect(reverseGeocoded.body.data).toEqual( + expect.objectContaining({ + address: expect.stringContaining('샌프란시스코'), + attribution: '© OpenStreetMap contributors', + source: 'reverse-geocode', + title: '샌프란시스코', + }), + ); + expect(reverseGeocodeFetch).toHaveBeenCalledWith( + expect.stringContaining('accept-language=ko%2Cen'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Accept-Language': 'ko,en;q=0.8', + }), + }), + ); + + const cachedReverseGeocoded = await request(app) + .get('/v1/tour/reverse-geocode') + .set('Authorization', authHeader) + .query({ lat: 37.785834, lng: -122.406417 }); + + expect(cachedReverseGeocoded.status).toBe(200); + expect(cachedReverseGeocoded.body.data.title).toBe('샌프란시스코'); + expect(reverseGeocodeFetch).toHaveBeenCalledTimes(1); + } finally { + reverseGeocodeFetch.mockRestore(); + } + const featured = await request(app) .get('/v1/home/featured-playlists') .set('Authorization', authHeader) .query({ locationRecommendationEnabled: true, lat: 35.1532, lng: 129.1186 }); expect(featured.status).toBe(200); expect(featured.body.data.length).toBeGreaterThan(0); + expect( + featured.body.data.some((playlist: { source?: string }) => + playlist.source === 'personalized', + ), + ).toBe(false); const unauthenticatedFeatured = await request(app) .get('/v1/home/featured-playlists') @@ -331,12 +474,55 @@ describe('Soundlog API', () => { expect(unauthenticatedFeatured.status).toBe(401); expect(unauthenticatedFeatured.body.error.code).toBe('UNAUTHORIZED'); - const mood = await request(app) + const playlistIds = new Set(); + + for (const moodFilter of ['잔잔한', '신나는', '시원한', '설레는', '감성적인']) { + const mood = await request(app) + .get('/v1/home/mood-recommendations') + .set('Authorization', authHeader) + .query({ moodFilter, preferredGenres: 'K-POP' }); + + expect(mood.status).toBe(200); + expect(mood.body.data.length).toBeGreaterThan(0); + expect( + mood.body.data.every((item: { moods: string[] }) => + item.moods.includes(moodFilter), + ), + ).toBe(true); + + for (const item of mood.body.data as Array<{ + imageUrl?: string; + playlistId?: string; + track?: unknown; + }>) { + expect(item.imageUrl).toEqual(expect.any(String)); + expect(item.playlistId).toEqual(expect.any(String)); + expect(item.track).toBeDefined(); + playlistIds.add(item.playlistId as string); + } + } + + const legacyMood = await request(app) .get('/v1/home/mood-recommendations') .set('Authorization', authHeader) - .query({ topFilter: '청량한', moodFilter: '전체', preferredGenres: 'K-POP' }); - expect(mood.status).toBe(200); - expect(mood.body.data[0].track).toBeDefined(); + .query({ moodFilter: '청량한' }); + expect(legacyMood.status).toBe(200); + expect(legacyMood.body.data.length).toBeGreaterThan(0); + expect( + legacyMood.body.data.every((item: { moods: string[] }) => + item.moods.includes('시원한'), + ), + ).toBe(true); + + for (const playlistId of playlistIds) { + const playlist = await request(app) + .get(`/v1/playlists/${playlistId}`) + .set('Authorization', authHeader); + + expect(playlist.status).toBe(200); + expect(playlist.body.data.id).toBe(playlistId); + expect(playlist.body.data.tracks.length).toBeGreaterThan(0); + } await createTestMomentLog({ authHeader, @@ -400,6 +586,22 @@ describe('Soundlog API', () => { recommended.body.data.context.source, ); + const recommendedTrack = recommended.body.data.tracks[0]; + const savedRecommendation = await request(app) + .put(`/v1/library/tracks/${recommendedTrack.id}`) + .set('Authorization', authHeader) + .send({ action: 'save', playlistId: recommended.body.data.id }); + expect(savedRecommendation.status).toBe(200); + expect(savedRecommendation.body.data.isSaved).toBe(true); + + const recommendedDetail = await request(app) + .get(`/v1/playlists/${recommended.body.data.id}`) + .set('Authorization', authHeader); + expect(recommendedDetail.status).toBe(200); + expect(recommendedDetail.body.data.tracks).toEqual( + expect.arrayContaining([expect.objectContaining({ id: recommendedTrack.id })]), + ); + const invalidRecommendation = await request(app) .get('/v1/recommendations/playlists') .set('Authorization', authHeader) @@ -716,11 +918,14 @@ describe('Soundlog API', () => { }); it('handles recap APIs', async () => { - const recapSessionId = `recap-session-${Date.now()}`; - const farRecapSessionId = `recap-session-far-${Date.now()}`; - const noLocationRecapSessionId = `recap-session-no-location-${Date.now()}`; - - await createTestMomentLog({ + const recapSessionId = await createTestTravelSession(authHeader); + const farRecapSessionId = await createTestTravelSession(authHeader); + const noLocationRecapSessionId = await createTestTravelSession(authHeader); + const mlRecapSessionId = await createTestTravelSession(authHeader); + const noMusicRecapSessionId = await createTestTravelSession(authHeader); + const mlTrackId = `ml-track-${Date.now()}`; + + const firstRecapMoment = await createTestMomentLog({ authHeader, filename: 'recap-moment-1.jpg', lat: 37.5512, @@ -728,8 +933,9 @@ describe('Soundlog API', () => { placeName: '리캡 테스트 장소', sessionId: recapSessionId, trackId: 'seoul-night-track', + visibility: 'public', }); - await createTestMomentLog({ + const secondRecapMoment = await createTestMomentLog({ authHeader, filename: 'recap-moment-2.jpg', lat: 37.552, @@ -737,6 +943,7 @@ describe('Soundlog API', () => { placeName: '리캡 테스트 장소', sessionId: recapSessionId, trackId: 'seoul-night-track', + visibility: 'public', }); await createTestMomentLog({ authHeader, @@ -753,8 +960,69 @@ describe('Soundlog API', () => { placeName: '멀리 있는 공개 리캡', sessionId: farRecapSessionId, trackId: 'seoul-night-track', + visibility: 'public', + }); + const mlMoment = await createTestMomentLog({ + artistName: 'ML Artist', + authHeader, + filename: 'recap-moment-ml.jpg', + lat: 37.5514, + lng: 126.9884, + placeName: 'ML 추천 리캡 테스트', + sessionId: mlRecapSessionId, + trackId: mlTrackId, + trackTitle: 'ML Recommended Track', }); + const mlTrackRecap = await request(app) + .post('/v1/recaps') + .set('Authorization', authHeader) + .send({ + momentLogIds: [mlMoment.id], + representativeTrackId: mlTrackId, + sessionId: mlRecapSessionId, + templateId: 'album', + title: 'ML 추천곡 리캡', + }); + expect(mlTrackRecap.status).toBe(201); + expect(mlTrackRecap.body.data.representativeTrack).toMatchObject({ + artist: 'ML Artist', + id: mlTrackId, + title: 'ML Recommended Track', + }); + const noMusicMoment = await createTestMomentLog({ + authHeader, + filename: 'recap-moment-no-music.jpg', + lat: 37.5515, + lng: 126.9885, + placeName: '음악 없는 리캡 테스트', + sessionId: noMusicRecapSessionId, + trackId: null, + }); + + const noMusicRecap = await request(app) + .post('/v1/recaps') + .set('Authorization', authHeader) + .send({ + momentLogIds: [noMusicMoment.id], + sessionId: noMusicRecapSessionId, + templateId: 'album', + title: '음악 없는 리캡', + }); + expect(noMusicRecap.status).toBe(201); + expect(noMusicRecap.body.data.representativeTrack).toMatchObject({ + artist: 'Soundlog', + id: 'soundlog-no-music', + title: '음악 없음', + }); + + const emptyRecap = await request(app) + .post('/v1/recaps') + .set('Authorization', authHeader) + .send({ templateId: 'album', title: '빈 로그' }); + expect(emptyRecap.status).toBe(400); + expect(emptyRecap.body.error.code).toBe('BAD_REQUEST'); + const publicNoLocationCreate = await request(app) .post('/v1/recaps') .set('Authorization', authHeader) @@ -803,11 +1071,18 @@ describe('Soundlog API', () => { expect(created.status).toBe(201); createdRecapId = created.body.data.id; expect(created.body.data.representativeTrack.id).toBe('seoul-night-track'); + expect(created.body.data.thumbnailMomentId).toBe(firstRecapMoment.id); + expect(created.body.data.backgroundImageUrl).toBe(firstRecapMoment.photoUrl); expect(created.body.data.visibility).toBe('private'); 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); + expect( + list.body.data.every( + (recap: { sessionId?: string }) => typeof recap.sessionId === 'string', + ), + ).toBe(true); const mineMarkers = await request(app) .get('/v1/recap-markers') @@ -846,6 +1121,15 @@ describe('Soundlog API', () => { expect(farCreated.status).toBe(201); expect(farCreated.body.data.visibility).toBe('public'); + const allMineMarkers = await request(app) + .get('/v1/recap-markers') + .query({ scope: 'mine' }) + .set('Authorization', authHeader); + expect(allMineMarkers.status).toBe(200); + expect( + allMineMarkers.body.data.map((marker: { recapId: string }) => marker.recapId), + ).toEqual(expect.arrayContaining([createdRecapId, farCreated.body.data.id])); + const otherEmail = `public-log-${Date.now()}@soundlog.test`; const otherRegister = await request(app).post('/v1/auth/register').send({ displayName: 'Public Log Traveler', @@ -859,9 +1143,9 @@ describe('Soundlog API', () => { }); expect(otherLogin.status).toBe(200); const otherAuthHeader = `Bearer ${otherLogin.body.data.accessToken}`; - const otherRecapSessionId = `other-recap-session-${Date.now()}`; + const otherRecapSessionId = await createTestTravelSession(otherAuthHeader); - await createTestMomentLog({ + const otherFirstPublicMoment = await createTestMomentLog({ authHeader: otherAuthHeader, filename: 'other-public-recap.jpg', lat: 37.5513, @@ -869,6 +1153,27 @@ describe('Soundlog API', () => { placeName: '다른 사람 공개 리캡', sessionId: otherRecapSessionId, trackId: 'seoul-city', + visibility: 'public', + }); + const otherPrivateMoment = await createTestMomentLog({ + authHeader: otherAuthHeader, + filename: 'other-private-recap.jpg', + lat: 37.5514, + lng: 126.9884, + placeName: '비공개 장소 이름', + sessionId: otherRecapSessionId, + trackId: 'moon-seoul', + visibility: 'private', + }); + await createTestMomentLog({ + authHeader: otherAuthHeader, + filename: 'other-latest-public-recap.jpg', + lat: 37.5515, + lng: 126.9885, + placeName: '마지막 공개 리캡', + sessionId: otherRecapSessionId, + trackId: 'hangang', + visibility: 'public', }); const otherPublicCreated = await request(app) @@ -931,6 +1236,16 @@ describe('Soundlog API', () => { marker.recapId === createdRecapId && marker.visibility === 'public', ), ).toBe(true); + const mixedVisibilityMarker = publicMarkersAfterUpdate.body.data.find( + (marker: { recapId: string }) => marker.recapId === otherPublicCreated.body.data.id, + ); + expect(mixedVisibilityMarker).toMatchObject({ + artistName: '폴킴', + location: { lat: 37.5515, lng: 126.9885 }, + placeName: '마지막 공개 리캡', + trackTitle: '한강에서', + }); + expect(JSON.stringify(mixedVisibilityMarker)).not.toContain('비공개 장소 이름'); const fixedRadiusMarkers = await request(app) .get('/v1/recap-markers') @@ -956,8 +1271,13 @@ describe('Soundlog API', () => { .set('Authorization', authHeader); expect(share.status).toBe(200); expect(share.body.data.id).toBe(createdRecapId); + expect(share.body.data.isMine).toBe(true); expect(share.body.data.trackTitle).toBe(created.body.data.representativeTrack.title); + expect(share.body.data.templateId).toBe('album'); expect(share.body.data.visibility).toBe('public'); + expect(share.body.data.sessionId).toBe(recapSessionId); + expect(share.body.data.thumbnailMomentId).toBe(firstRecapMoment.id); + expect(share.body.data.backgroundImageUrl).toBe(firstRecapMoment.photoUrl); expect(share.body.data.moments.length).toBeGreaterThan(1); expect(share.body.data.routePoints).toEqual(routePoints); expect(share.body.data.moments.map((moment: { location?: unknown }) => moment.location)).toEqual( @@ -967,11 +1287,117 @@ describe('Soundlog API', () => { ]), ); + const thumbnailUpdate = await request(app) + .patch(`/v1/recaps/${createdRecapId}/thumbnail`) + .set('Authorization', authHeader) + .send({ momentId: secondRecapMoment.id }); + expect(thumbnailUpdate.status).toBe(200); + expect(thumbnailUpdate.body.data.thumbnailMomentId).toBe(secondRecapMoment.id); + expect(thumbnailUpdate.body.data.backgroundImageUrl).toBe(secondRecapMoment.photoUrl); + + const invalidThumbnailUpdate = await request(app) + .patch(`/v1/recaps/${createdRecapId}/thumbnail`) + .set('Authorization', authHeader) + .send({ momentId: 'moment-not-in-this-log' }); + expect(invalidThumbnailUpdate.status).toBe(400); + + const forbiddenThumbnailUpdate = await request(app) + .patch(`/v1/recaps/${createdRecapId}/thumbnail`) + .set('Authorization', otherAuthHeader) + .send({ momentId: secondRecapMoment.id }); + expect(forbiddenThumbnailUpdate.status).toBe(404); + const publicShareAsOther = await request(app) .get(`/v1/recaps/${createdRecapId}/share`) .set('Authorization', otherAuthHeader); expect(publicShareAsOther.status).toBe(200); + expect(publicShareAsOther.body.data.isMine).toBe(false); expect(publicShareAsOther.body.data.routePoints).toBeUndefined(); + expect(publicShareAsOther.body.data.thumbnailMomentId).toBe(secondRecapMoment.id); + expect(publicShareAsOther.body.data.backgroundImageUrl).toBe(secondRecapMoment.photoUrl); + + const mixedVisibilityShare = await request(app) + .get(`/v1/recaps/${otherPublicCreated.body.data.id}/share`) + .set('Authorization', authHeader); + expect(mixedVisibilityShare.status).toBe(200); + expect(mixedVisibilityShare.body.data.moments).toHaveLength(2); + expect( + mixedVisibilityShare.body.data.moments.every( + (moment: { visibility: string }) => moment.visibility === 'public', + ), + ).toBe(true); + expect(JSON.stringify(mixedVisibilityShare.body.data)).not.toContain('비공개 장소 이름'); + + const privateThumbnailSelection = await request(app) + .patch(`/v1/recaps/${otherPublicCreated.body.data.id}/thumbnail`) + .set('Authorization', otherAuthHeader) + .send({ momentId: otherPrivateMoment.id }); + expect(privateThumbnailSelection.status).toBe(200); + expect(privateThumbnailSelection.body.data.thumbnailMomentId).toBe(otherPrivateMoment.id); + + const publicShareAfterPrivateThumbnail = await request(app) + .get(`/v1/recaps/${otherPublicCreated.body.data.id}/share`) + .set('Authorization', authHeader); + expect(publicShareAfterPrivateThumbnail.status).toBe(200); + expect(publicShareAfterPrivateThumbnail.body.data.thumbnailMomentId).toBe( + otherFirstPublicMoment.id, + ); + expect(publicShareAfterPrivateThumbnail.body.data.backgroundImageUrl).toBe( + otherFirstPublicMoment.photoUrl, + ); + expect(JSON.stringify(publicShareAfterPrivateThumbnail.body.data)).not.toContain( + otherPrivateMoment.photoUrl, + ); + + const aggregateUpdate = await request(app) + .patch(`/v1/moment-logs/${secondRecapMoment.id}`) + .set('Authorization', authHeader) + .send({ placeName: '수정된 리캡 장소' }); + expect(aggregateUpdate.status).toBe(200); + + const refreshedShare = await request(app) + .get(`/v1/recaps/${createdRecapId}/share`) + .set('Authorization', authHeader); + expect(refreshedShare.status).toBe(200); + expect(refreshedShare.body.data.placeName).toBe('수정된 리캡 장소'); + expect(refreshedShare.body.data.thumbnailMomentId).toBe(secondRecapMoment.id); + + const aggregateDelete = await request(app) + .delete(`/v1/moment-logs/${secondRecapMoment.id}`) + .set('Authorization', authHeader); + expect(aggregateDelete.status).toBe(202); + + const shareAfterDelete = await request(app) + .get(`/v1/recaps/${createdRecapId}/share`) + .set('Authorization', authHeader); + expect(shareAfterDelete.status).toBe(200); + expect(shareAfterDelete.body.data.moments).toHaveLength(1); + expect(shareAfterDelete.body.data.moments[0].id).toBe(firstRecapMoment.id); + expect(shareAfterDelete.body.data.thumbnailMomentId).toBe(firstRecapMoment.id); + expect(shareAfterDelete.body.data.backgroundImageUrl).toBe(firstRecapMoment.photoUrl); + + const recoveredSessionId = `offline-session-${Date.now()}`; + const offlineMoment = await createTestMomentLog({ + authHeader, + filename: 'offline-session-recap.jpg', + lat: 37.5518, + lng: 126.9888, + placeName: '오프라인 여행 기록', + sessionId: recoveredSessionId, + trackId: 'seoul-city', + }); + const recoveredLog = await request(app) + .post('/v1/recaps') + .set('Authorization', authHeader) + .set('Idempotency-Key', `travel-log:${recoveredSessionId}`) + .send({ + momentLogIds: [offlineMoment.id], + sessionId: recoveredSessionId, + templateId: 'album', + title: '복구된 오프라인 로그', + }); + expect(recoveredLog.status).toBe(201); + expect(recoveredLog.body.data.sessionId).toBe(recoveredSessionId); const shareEvent = await request(app) .post(`/v1/recaps/${createdRecapId}/share-events`) diff --git a/tests/mock-soundlog.service.test.ts b/tests/mock-soundlog.service.test.ts index 1a15adb..379d543 100644 --- a/tests/mock-soundlog.service.test.ts +++ b/tests/mock-soundlog.service.test.ts @@ -36,7 +36,16 @@ describe('mockSoundlogService', () => { preferredMoods: ['잔잔한'], travelStyles: ['산책'], }); - const places = await mockSoundlogService.getNearbyPlaces({ lat: 35.1532, limit: 2 }); + const places = await mockSoundlogService.getNearbyPlaces({ + lat: 35.1532, + limit: 2, + lng: 129.1186, + }); + const distantPlaces = await mockSoundlogService.getNearbyPlaces({ + lat: 37.785834, + lng: -122.406417, + radiusMeters: 2000, + }); const playlists = await mockSoundlogService.getFeaturedPlaylists(undefined, { lat: 35.1532, limit: 3, @@ -71,17 +80,25 @@ describe('mockSoundlogService', () => { expect(profile.completedOnboarding).toBe(true); expect(profile.preferredGenres).toEqual(['인디']); expect(profile.dislikedArtists).toEqual(['skip-me']); - expect(places).toHaveLength(2); + expect(places).toHaveLength(1); expect(places[0].source).toBe('seed'); expect(places[0].id).toMatch(/^seed-/); + expect(places[0].distanceMeters).toBe(0); expect(playlists[0].id).toBe('busan-ocean'); + expect(playlists.map((playlist) => playlist.id)).not.toEqual( + expect.arrayContaining(['calm-walk', 'drive', 'city-night', 'cafe-indie']), + ); expect(placeScopedPlaylists[0].id).toBe('busan-ocean'); expect(recommendations[0].track.id).toEqual(expect.any(String)); + expect(recommendations[0].moods).toContain('시원한'); + expect(recommendations[0].playlistId).toEqual(expect.any(String)); expect(contextualPlaylist.context).toMatchObject({ source: 'seed-fallback', state: '산책', travelMode: 'walk', }); + + expect(distantPlaces).toEqual([]); expect(recommendedPlaylist.context).toMatchObject({ source: 'seed-fallback', state: '바다', @@ -241,6 +258,7 @@ describe('mockSoundlogService', () => { }); it('creates recaps, share payloads, share events, and validates representative tracks', async () => { + const recapSession = await mockSoundlogService.createTravelSession(ownerId, {}); const moment = await mockSoundlogService.createMomentLog(ownerId, { createdAt: '2026-07-09T10:00:00.000Z', lat: 37.5444, @@ -248,7 +266,7 @@ describe('mockSoundlogService', () => { moodTags: ['감성적인'], photoPath: '/uploads/recap.jpg', placeName: '서울숲', - sessionId: 'recap-session', + sessionId: recapSession.id, trackId: 'seoul-city', }); const momentId = requireValue(moment.id); @@ -256,7 +274,7 @@ describe('mockSoundlogService', () => { ownerId, { momentLogIds: [momentId], - sessionId: 'recap-session', + sessionId: recapSession.id, title: '서울숲 사운드', }, 'recap-key-a', @@ -287,6 +305,7 @@ describe('mockSoundlogService', () => { expect(share.moments).toHaveLength(1); expect(mockDb.recapShareEvents).toHaveLength(1); + const otherRecapSession = await mockSoundlogService.createTravelSession('other-user', {}); const otherMoment = await mockSoundlogService.createMomentLog('other-user', { createdAt: '2026-07-09T10:20:00.000Z', lat: 37.5446, @@ -294,12 +313,13 @@ describe('mockSoundlogService', () => { moodTags: ['감성적인'], photoPath: '/uploads/other-recap.jpg', placeName: '다른 사람 서울숲', - sessionId: 'other-recap-session', + sessionId: otherRecapSession.id, trackId: 'seoul-city', + visibility: 'public', }); const otherPublicRecap = await mockSoundlogService.createRecap('other-user', { momentLogIds: [requireValue(otherMoment.id)], - sessionId: 'other-recap-session', + sessionId: otherRecapSession.id, title: '다른 사람 공개 로그', visibility: 'public', }); @@ -315,8 +335,18 @@ describe('mockSoundlogService', () => { expect.arrayContaining([recapId, otherPublicRecap.id]), ); + const standaloneMoment = await mockSoundlogService.createMomentLog(ownerId, { + createdAt: '2026-07-09T10:30:00.000Z', + moodTags: ['감성적인'], + placeName: '독립 리캡', + trackId: 'seoul-city', + }); + await expect( - mockSoundlogService.createRecap(ownerId, { representativeTrackId: 'missing-track' }), + mockSoundlogService.createRecap(ownerId, { + momentLogIds: [requireValue(standaloneMoment.id)], + representativeTrackId: 'missing-track', + }), ).rejects.toSatisfy((error) => { expectHttpError(error, 404); return true; diff --git a/tests/seed-data.test.ts b/tests/seed-data.test.ts new file mode 100644 index 0000000..dc151ea --- /dev/null +++ b/tests/seed-data.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + moodRecommendations, + playlists, + recaps, + seedMomentLogs, +} from '../src/data/seed-data.js'; + +describe('seed recap/log domain consistency', () => { + it('keeps one log per travel session and includes every session recap', () => { + const sessionIds: string[] = recaps.map((recap) => recap.sessionId); + + expect(new Set(sessionIds).size).toBe(sessionIds.length); + + for (const recap of recaps) { + const sessionMoments = seedMomentLogs.filter( + (moment) => moment.sessionId === recap.sessionId, + ); + const storedMomentIds = recap.moments.map((moment) => moment.id).sort(); + const sourceMomentIds = sessionMoments.map((moment) => moment.id).sort(); + const latestRecordedAt = sessionMoments + .map((moment) => moment.createdAt) + .sort() + .at(-1); + + expect(recap.momentCount).toBe(sessionMoments.length); + expect(storedMomentIds).toEqual(sourceMomentIds); + expect(recap.recordedAt).toBe(latestRecordedAt); + expect(recap.routePoints).toHaveLength(sessionMoments.length); + expect( + sessionMoments.every((moment) => moment.visibility === recap.visibility), + ).toBe(true); + } + }); + + it('connects every supported mood to an existing playlist', () => { + const supportedMoods = ['잔잔한', '신나는', '시원한', '설레는', '감성적인']; + const playlistIds = new Set(playlists.map((playlist) => playlist.id)); + + for (const mood of supportedMoods) { + expect( + moodRecommendations.some((recommendation) => + (recommendation.moods as readonly string[]).includes(mood), + ), + ).toBe(true); + } + + for (const recommendation of moodRecommendations) { + expect(playlistIds.has(recommendation.playlistId)).toBe(true); + expect(recommendation.imageUrl).toMatch(/^https:\/\//); + } + }); +}); From 75e822124b92d689e5d0162cbeffbf3356e5af61 Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 14 Jul 2026 23:06:31 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EB=A6=AC=EC=BA=A1=20=ED=95=80=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=EC=99=80=20CI=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EA=B2=A9=EB=A6=AC=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/mock-soundlog.service.ts | 2 +- tests/api.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index 8760a82..9a51eb4 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -378,7 +378,7 @@ function getMockRecapMomentLocation( viewerId?: string, ) { const moments = getMockVisibleRecapMoments(recap, viewerId); - const location = moments.find( + const location = [...moments].reverse().find( (moment) => typeof moment.location?.lat === 'number' && typeof moment.location?.lng === 'number', diff --git a/tests/api.test.ts b/tests/api.test.ts index aa23517..f8bfee9 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -424,7 +424,7 @@ describe('Soundlog API', () => { const reverseGeocoded = await request(app) .get('/v1/tour/reverse-geocode') .set('Authorization', authHeader) - .query({ lat: 37.785834, lng: -122.406417 }); + .query({ lat: 37.786834, lng: -122.407417 }); expect(reverseGeocoded.status).toBe(200); expect(reverseGeocoded.body.data).toEqual( @@ -447,7 +447,7 @@ describe('Soundlog API', () => { const cachedReverseGeocoded = await request(app) .get('/v1/tour/reverse-geocode') .set('Authorization', authHeader) - .query({ lat: 37.785834, lng: -122.406417 }); + .query({ lat: 37.786834, lng: -122.407417 }); expect(cachedReverseGeocoded.status).toBe(200); expect(cachedReverseGeocoded.body.data.title).toBe('샌프란시스코'); From 62343bf22d510e20dd29559666ce772a1e9132fd Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 14 Jul 2026 23:08:28 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test:=20CI=20=EC=97=AD=EC=A7=80=EC=98=A4?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EA=B2=80=EC=A6=9D=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/api.test.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/tests/api.test.ts b/tests/api.test.ts index f8bfee9..166ae72 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -5,6 +5,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; import { createApp } from '../src/app.js'; import { prisma } from '../src/config/prisma.js'; import { mockDb, resetMockDb } from '../src/mock/mock-db.js'; +import { reverseGeocodeLocation } from '../src/services/reverse-geocoding.service.js'; import { disconnectSeedDatabase, seedDatabase } from '../prisma/seed.js'; const app = createApp(); @@ -421,13 +422,12 @@ describe('Soundlog API', () => { ); try { - const reverseGeocoded = await request(app) - .get('/v1/tour/reverse-geocode') - .set('Authorization', authHeader) - .query({ lat: 37.786834, lng: -122.407417 }); + const reverseGeocodeLocationParams = { lat: 37.786834, lng: -122.407417 }; + const reverseGeocodedServiceResult = await reverseGeocodeLocation( + reverseGeocodeLocationParams, + ); - expect(reverseGeocoded.status).toBe(200); - expect(reverseGeocoded.body.data).toEqual( + expect(reverseGeocodedServiceResult).toEqual( expect.objectContaining({ address: expect.stringContaining('샌프란시스코'), attribution: '© OpenStreetMap contributors', @@ -444,13 +444,26 @@ describe('Soundlog API', () => { }), ); - const cachedReverseGeocoded = await request(app) + const reverseGeocoded = await request(app) .get('/v1/tour/reverse-geocode') .set('Authorization', authHeader) - .query({ lat: 37.786834, lng: -122.407417 }); + .query(reverseGeocodeLocationParams); + + expect(reverseGeocoded.status).toBe(200); + expect(reverseGeocoded.body.data).toEqual( + expect.objectContaining({ + address: expect.stringContaining('샌프란시스코'), + attribution: '© OpenStreetMap contributors', + source: 'reverse-geocode', + title: '샌프란시스코', + }), + ); + + const cachedReverseGeocoded = await reverseGeocodeLocation( + reverseGeocodeLocationParams, + ); - expect(cachedReverseGeocoded.status).toBe(200); - expect(cachedReverseGeocoded.body.data.title).toBe('샌프란시스코'); + expect(cachedReverseGeocoded?.title).toBe('샌프란시스코'); expect(reverseGeocodeFetch).toHaveBeenCalledTimes(1); } finally { reverseGeocodeFetch.mockRestore();