Skip to content
Merged
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
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ 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 public ./public
COPY --chown=node:node scripts/check-public-api-contract.mjs ./scripts/check-public-api-contract.mjs
COPY --chown=node:node scripts/start-container.sh ./scripts/start-container.sh

RUN mkdir -p uploads && chown -R node:node /app

USER node

EXPOSE 4000

CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy && node dist/prisma/seed.js --public-catalog && node dist/src/server.js"]
CMD ["sh", "scripts/start-container.sh"]
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ docker compose --profile seed run --rm seed
curl http://localhost:4000/v1/health
```

API 컨테이너는 시작 시 `prisma migrate deploy`를 먼저 실행합니다. seed는 기존 사용자 데이터를 초기화할 수 있으므로 필요할 때만 별도 프로필로 실행합니다.
API 컨테이너는 시작할 때 `prisma migrate deploy`를 실행한 뒤 `--public-catalog` 공개 시드를 멱등 적용합니다. 이 시작 시드는 지역 플레이리스트와 곡 및 장소 카탈로그만 `upsert`하며 기존 사용자와 리캡 데이터는 초기화하지 않습니다. 전체 데모 seed는 필요한 로컬 환경에서만 별도로 실행합니다.

## Prisma schema

Expand Down
6 changes: 6 additions & 0 deletions openapi/soundlog-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2371,6 +2371,12 @@ components:
description:
type: string
example: 부산광역시에 어울리는 노래를 추천해드립니다.
coverImageUrl:
type: string
format: uri
backgroundImageUrl:
type: string
format: uri
trackCount:
type: integer
example: 12
Expand Down
Binary file added public/assets/playlists/andong.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/busan.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/chuncheon.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/daegu.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/daejeon.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/gangneung.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/geoje.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/gwangju.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/gyeongju.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/incheon.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/jeju.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/jeonju.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/seoul.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/sokcho.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/suwon.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/tongyeong.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/playlists/yeosu.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 37 additions & 3 deletions scripts/check-public-api-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,47 @@ async function verifyMusicMetadata() {

async function verifyPlaylistCatalog() {
try {
const payload = await fetchJson('/v1/playlists/geoje-ocean', {
const payload = await fetchJson('/v1/playlists/jeju-island', {
authenticated: true,
});
const playlist = payload?.data;

if (playlist?.id !== 'geoje-ocean' || !Array.isArray(playlist.tracks) || playlist.tracks.length === 0) {
addError('/v1/playlists/geoje-ocean did not return a seeded playlist with tracks.');
if (playlist?.id !== 'jeju-island' || !Array.isArray(playlist.tracks) || playlist.tracks.length === 0) {
addError('/v1/playlists/jeju-island did not return a seeded playlist with tracks.');
}

if (typeof playlist?.backgroundImageUrl !== 'string') {
addError('/v1/playlists/jeju-island did not return a background image URL.');
} else {
const artworkPath = new URL(playlist.backgroundImageUrl).pathname;
const { response } = await fetchText(artworkPath);

if (!response.ok || response.headers.get('content-type') !== 'image/webp') {
addError(`${artworkPath} did not return a WebP playlist image.`);
}
}

const featuredPayload = await fetchJson(
'/v1/home/featured-playlists?limit=20&locationRecommendationEnabled=true&recommendationMode=travel&lat=33.4996&lng=126.5312',
{ authenticated: true },
);
const featuredIds = new Set(
Array.isArray(featuredPayload?.data)
? featuredPayload.data.map((item) => item?.id)
: [],
);

if (
!Array.isArray(featuredPayload?.data) ||
featuredPayload.data.some((item) => typeof item?.coverImageUrl !== 'string')
) {
addError('/v1/home/featured-playlists contains an item without a cover image URL.');
}

for (const regionalPlaylistId of ['jeju-island', 'gangneung-sea', 'yeosu-night-sea']) {
if (!featuredIds.has(regionalPlaylistId)) {
addError(`/v1/home/featured-playlists is missing ${regionalPlaylistId}.`);
}
}
} catch (error) {
addError(error instanceof Error ? error.message : String(error));
Expand Down
7 changes: 7 additions & 0 deletions scripts/start-container.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/sh

set -eu

./node_modules/.bin/prisma migrate deploy
node dist/prisma/seed.js --public-catalog
exec node dist/src/server.js
8 changes: 8 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import express from 'express';
import path from 'node:path';

import { env } from './config/env.js';
import { ERROR_MESSAGES } from './constants/error.constants.js';
Expand Down Expand Up @@ -32,6 +33,13 @@ export function createApp() {
app.use(urlencodedBodyParserMiddleware);
app.use(requestLoggerMiddleware);
app.use(createLegalRouter());
app.use(
'/assets',
express.static(path.resolve('public/assets'), {
immutable: true,
maxAge: '1y',
}),
);
// Uploaded photos are served only through an authenticated, ownership/visibility-checked
// endpoint (see uploads.router.ts) — there is no unauthenticated static file serving of
// the uploads directory.
Expand Down
104 changes: 104 additions & 0 deletions src/data/regional-playlist-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
export const regionalPlaylistFallbacks = [
{
aliases: ['서울', '남산', '한강', '홍대', '성수'],
lat: 37.5665,
lng: 126.978,
playlistId: 'seoul-night',
},
{
aliases: ['부산', '광안리', '해운대', '태종대'],
lat: 35.1796,
lng: 129.0756,
playlistId: 'busan-ocean',
},
{
aliases: ['거제', '바람의 언덕', '매미성'],
lat: 34.8806,
lng: 128.6211,
playlistId: 'geoje-ocean',
},
{
aliases: ['제주', '서귀포', '성산', '우도', '애월'],
lat: 33.4996,
lng: 126.5312,
playlistId: 'jeju-island',
},
{
aliases: ['강릉', '경포대', '안목'],
lat: 37.7519,
lng: 128.8761,
playlistId: 'gangneung-sea',
},
{
aliases: ['속초', '설악산', '대포항'],
lat: 38.207,
lng: 128.5918,
playlistId: 'sokcho-sunrise',
},
{
aliases: ['춘천', '남이섬', '소양강'],
lat: 37.8813,
lng: 127.7298,
playlistId: 'chuncheon-train',
},
{
aliases: ['인천', '송도', '월미도', '영종도'],
lat: 37.4563,
lng: 126.7052,
playlistId: 'incheon-sunset',
},
{
aliases: ['수원', '화성행궁', '광교'],
lat: 37.2636,
lng: 127.0286,
playlistId: 'suwon-fortress',
},
{
aliases: ['대전', '성심당', '엑스포'],
lat: 36.3504,
lng: 127.3845,
playlistId: 'daejeon-city',
},
{
aliases: ['대구', '동성로', '수성못'],
lat: 35.8714,
lng: 128.6014,
playlistId: 'daegu-energy',
},
{
aliases: ['안동', '하회마을', '월영교'],
lat: 36.5684,
lng: 128.7294,
playlistId: 'andong-heritage',
},
{
aliases: ['경주', '불국사', '첨성대', '황리단길'],
lat: 35.8562,
lng: 129.2247,
playlistId: 'gyeongju-starlight',
},
{
aliases: ['전주', '한옥마을', '경기전'],
lat: 35.8242,
lng: 127.148,
playlistId: 'jeonju-hanok',
},
{
aliases: ['광주', '무등산', '양림동'],
lat: 35.1595,
lng: 126.8526,
playlistId: 'gwangju-art',
},
{
aliases: ['여수', '오동도', '돌산'],
lat: 34.7604,
lng: 127.6622,
playlistId: 'yeosu-night-sea',
},
{
aliases: ['통영', '동피랑', '미륵산'],
lat: 34.8544,
lng: 128.4332,
playlistId: 'tongyeong-harbor',
},
] as const;
Loading
Loading