Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 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.

## 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.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 데이터 적재
Expand All @@ -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`: 서버 기준 여행 모드 시작
Expand Down Expand Up @@ -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`
2 changes: 2 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions docs/ec2-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions docs/recap-log-domain-contract.md
Original file line number Diff line number Diff line change
@@ -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`를 함께 따른다.
Loading
Loading