From 86a9cb54fba1bfca396270a9440ac77a6df0961b Mon Sep 17 00:00:00 2001 From: manNomi Date: Sat, 15 Aug 2026 17:12:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B3=A0=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=EA=B3=BC=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EA=B2=80=EC=88=98=20?= =?UTF-8?q?=EC=B2=B4=EA=B3=84=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + Dockerfile | 1 + docker-compose.prod.yml | 6 + docs/moderation-operations.md | 72 +++ openapi/soundlog-api.yaml | 203 ++++++- package.json | 3 +- .../migration.sql | 40 ++ prisma/models/auth.prisma | 5 + prisma/models/community.prisma | 15 + prisma/models/recap.prisma | 3 + prisma/models/travel.prisma | 3 + prisma/seed-review.ts | 264 +++++++++ scripts/check-live-e2e.mjs | 2 + scripts/check-production-env.mjs | 23 + scripts/check-public-api-contract.mjs | 2 + scripts/start-container.sh | 1 + src/config/env.ts | 7 + src/constants/error.constants.ts | 4 + src/constants/legal.constants.ts | 1 + src/controllers/community.controller.ts | 3 +- src/controllers/index.ts | 1 + src/controllers/moderation.controller.ts | 53 ++ src/middlewares/auth.middleware.ts | 26 +- .../moderation-admin.middleware.ts | 26 + src/middlewares/rate-limit.middleware.ts | 10 + src/mock/mock-db.ts | 15 + src/routes/index.ts | 47 ++ src/routes/legal.router.ts | 8 +- src/server.ts | 10 +- src/services/auth.service.ts | 83 ++- src/services/content-moderation.service.ts | 514 ++++++++++++++++++ src/services/mock-soundlog.service.ts | 220 +++++++- src/services/soundlog.service.ts | 321 ++++++++++- src/services/upload-file.service.ts | 49 +- src/validators/api.validators.ts | 53 +- tests/api.test.ts | 232 +++++++- tests/content-moderation.test.ts | 32 ++ tests/legal-pages.test.ts | 9 + tests/mock-soundlog.service.test.ts | 7 + 39 files changed, 2316 insertions(+), 67 deletions(-) create mode 100644 docs/moderation-operations.md create mode 100644 prisma/migrations/20260815000000_app_review_moderation/migration.sql create mode 100644 prisma/seed-review.ts create mode 100644 src/constants/legal.constants.ts create mode 100644 src/controllers/moderation.controller.ts create mode 100644 src/middlewares/moderation-admin.middleware.ts create mode 100644 src/services/content-moderation.service.ts create mode 100644 tests/content-moderation.test.ts diff --git a/.env.example b/.env.example index 22fa81a..49db987 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,15 @@ JWT_EXPIRES_IN_SECONDS=3600 # Optional. Production accepts HTTPS endpoints only; otherwise seed recommendations are used. ML_RECOMMENDATION_API_URL= ML_RECOMMENDATION_TIMEOUT_MS=5000 +# 앱 심사 대응용 신고 운영 API 비밀키입니다. 운영에서는 32자 이상으로 설정합니다. +MODERATION_ADMIN_KEY= +# cloud_logging은 GCP 구조화 로그를 사용합니다. webhook은 아래 HTTPS 주소가 필요합니다. +MODERATION_ALERT_MODE=cloud_logging +MODERATION_ALERT_WEBHOOK_URL= +MODERATION_SWEEP_INTERVAL_MS=900000 +# 심사 시연 데이터를 준비할 때만 사용하는 계정입니다. 저장소에 실제 비밀번호를 넣지 않습니다. +APP_REVIEW_EMAIL= +APP_REVIEW_PASSWORD= REQUEST_BODY_LIMIT=1mb MOMENT_PHOTO_MAX_FILE_SIZE_MB=10 REVERSE_GEOCODING_BASE_URL=https://nominatim.openstreetmap.org diff --git a/Dockerfile b/Dockerfile index 22c7470..68c3a6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,7 @@ 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/check-production-env.mjs ./scripts/check-production-env.mjs COPY --chown=node:node scripts/start-container.sh ./scripts/start-container.sh RUN mkdir -p uploads && chown -R node:node /app diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d5a528e..257b17b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -30,6 +30,12 @@ services: JWT_EXPIRES_IN_SECONDS: ${JWT_EXPIRES_IN_SECONDS:-3600} ML_RECOMMENDATION_API_URL: ${ML_RECOMMENDATION_API_URL:-} ML_RECOMMENDATION_TIMEOUT_MS: ${ML_RECOMMENDATION_TIMEOUT_MS:-5000} + MODERATION_ADMIN_KEY: ${MODERATION_ADMIN_KEY} + MODERATION_ALERT_MODE: ${MODERATION_ALERT_MODE} + MODERATION_ALERT_WEBHOOK_URL: ${MODERATION_ALERT_WEBHOOK_URL} + MODERATION_SWEEP_INTERVAL_MS: ${MODERATION_SWEEP_INTERVAL_MS:-900000} + APP_REVIEW_EMAIL: ${APP_REVIEW_EMAIL} + APP_REVIEW_PASSWORD: ${APP_REVIEW_PASSWORD} 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} diff --git a/docs/moderation-operations.md b/docs/moderation-operations.md new file mode 100644 index 0000000..e42ffdc --- /dev/null +++ b/docs/moderation-operations.md @@ -0,0 +1,72 @@ +# Soundlog 신고 운영 안내 + +이 문서는 신고가 들어온 뒤 24시간 안에 확인하고 조치하기 위한 운영 절차를 설명합니다. 앱 심사 전에 운영 서버에 `MODERATION_ADMIN_KEY`와 `MODERATION_ALERT_MODE`를 반드시 설정해야 합니다. + +## 운영 환경 설정 + +`MODERATION_ADMIN_KEY`는 관리자 API 요청을 인증하는 32자 이상의 비밀 문자열입니다. 앱 코드나 공개 문서에 넣지 않습니다. + +`MODERATION_ALERT_MODE=cloud_logging`은 새 신고와 처리 기한 알림을 `source=soundlog_moderation` 구조화 로그로 남깁니다. GCP에서는 이 로그에 Cloud Monitoring 알림 정책을 연결합니다. 외부 알림 도구를 사용할 때는 `MODERATION_ALERT_MODE=webhook`을 설정하고 `MODERATION_ALERT_WEBHOOK_URL`에 HTTPS 웹훅 주소를 넣습니다. + +`APP_REVIEW_EMAIL`과 `APP_REVIEW_PASSWORD`는 심사 계정과 시연 데이터를 준비할 때 사용합니다. 아래 명령은 공개 추천 카탈로그와 데모 사용자를 갱신한 뒤 심사 계정이 신고하고 차단할 수 있는 사운드맵 핀과 공유 여행방을 만듭니다. + +```bash +pnpm db:seed:review +``` + +## 신고 확인 + +아래 예시에서 주소와 비밀키는 운영 환경에 맞게 바꿉니다. + +```bash +curl -fsS 'https://api.soundlog.shop/v1/admin/moderation/reports?status=pending&limit=50' \ + -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' +``` + +응답의 `dueAt`이 처리 기한입니다. 신고 내용과 대상 스냅샷을 확인한 뒤 다음 중 하나로 처리합니다. + +- `dismiss`는 위반이 아니라고 판단한 신고를 종료합니다. +- `hide_content`는 신고된 콘텐츠를 숨기고 신고를 종료합니다. +- `hide_and_suspend`는 콘텐츠를 숨기고 작성자의 로그인을 중단합니다. + +```bash +curl -fsS -X PATCH 'https://api.soundlog.shop/v1/admin/moderation/reports/REPORT_ID' \ + -H 'content-type: application/json' \ + -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' \ + --data '{"action":"hide_content","note":"운영 정책 위반 콘텐츠를 숨겼습니다."}' +``` + +## 공개 사진 검토 + +사진이 포함된 공개 리캡과 음악 기록은 승인 전까지 다른 사용자에게 보이지 않습니다. + +```bash +curl -fsS 'https://api.soundlog.shop/v1/admin/moderation/content?limit=50' \ + -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' +``` + +응답의 `photoUrl` 또는 `backgroundImageUrl` 마지막 경로에 있는 32자리 `fileId`를 관리자 이미지 경로에 넣으면 사용자 로그인 토큰 없이 검토 원본을 확인할 수 있습니다. + +```bash +curl -fsS 'https://api.soundlog.shop/v1/admin/moderation/content-images/FILE_ID' \ + -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' \ + --output moderation-image +``` + +```bash +curl -fsS -X PATCH 'https://api.soundlog.shop/v1/admin/moderation/content/CONTENT_ID' \ + -H 'content-type: application/json' \ + -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' \ + --data '{"type":"recap","decision":"approved"}' +``` + +## 알림 점검 + +서버는 15분마다 처리 기한을 확인합니다. 20시간이 지난 미처리 신고와 24시간을 넘긴 신고를 웹훅으로 다시 알립니다. 배포 직후에는 아래 명령으로 점검 작업을 직접 실행하고 웹훅 수신 여부를 확인합니다. + +```bash +curl -fsS -X POST 'https://api.soundlog.shop/v1/admin/moderation/sweep' \ + -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' +``` + +웹훅이 오지 않더라도 신고 데이터는 저장됩니다. 다만 24시간 대응을 보장할 수 없으므로 심사 제출 전에는 실제 신고 한 건을 접수하고 운영 담당자가 알림과 관리자 API를 모두 확인해야 합니다. diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index 0dba585..c04ae26 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -15,8 +15,8 @@ info: servers: - url: http://localhost:4000 description: Local Docker/API server - - url: https://soundlog.shop/api/soundlog - description: Production API through the frontend Vercel proxy + - url: https://api.soundlog.shop + description: Production API tags: - name: System description: 서버 상태 확인 @@ -48,6 +48,8 @@ tags: description: 지역 사운드 트렌드 - name: Community description: 공동 여행방, Live Sound Map, 취향 매칭, 신고/차단 + - name: ModerationAdmin + description: 관리자 키로 보호되는 신고 및 공개 콘텐츠 운영 API security: - bearerAuth: [] paths: @@ -1830,6 +1832,11 @@ paths: schema: type: object properties: + targetType: + type: string + enum: [user, sound_pin, recap, travel_room_moment, travel_room_comment, mate_request] + targetContentId: + type: string targetUserId: type: string description: 대상 사용자 ID를 알고 있을 때 사용합니다. @@ -1864,10 +1871,163 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AcceptedResponse" + $ref: "#/components/schemas/CommunityReportAcceptedResponse" "401": $ref: "#/components/responses/Unauthorized" + /v1/admin/moderation/reports: + get: + security: + - moderationAdminKey: [] + tags: [ModerationAdmin] + summary: 신고 운영 큐 조회 + operationId: listModerationReports + parameters: + - name: x-soundlog-admin-key + in: header + required: true + schema: { type: string } + - name: status + in: query + schema: + type: string + enum: [pending, resolved, dismissed] + - name: limit + in: query + schema: { type: integer, default: 50 } + responses: + "200": { description: 처리 기한이 빠른 순서의 신고 목록 } + "401": { $ref: "#/components/responses/Unauthorized" } + + /v1/admin/moderation/reports/{reportId}: + patch: + security: + - moderationAdminKey: [] + tags: [ModerationAdmin] + summary: 신고 해결 및 콘텐츠 삭제와 사용자 제재 + operationId: resolveModerationReport + parameters: + - name: reportId + in: path + required: true + schema: { type: string } + - name: x-soundlog-admin-key + in: header + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [action, note] + properties: + action: + type: string + enum: [dismiss, hide_content, hide_and_suspend] + note: { type: string, maxLength: 500 } + responses: + "200": { description: 처리된 신고 } + "401": { $ref: "#/components/responses/Unauthorized" } + + /v1/admin/moderation/content: + get: + security: + - moderationAdminKey: [] + tags: [ModerationAdmin] + summary: 공개 전 이미지 검토 대기 목록 조회 + operationId: listPendingModerationContent + parameters: + - name: x-soundlog-admin-key + in: header + required: true + schema: { type: string } + - name: limit + in: query + schema: { type: integer, default: 50 } + responses: + "200": { description: 검토 대기 콘텐츠 목록 } + "401": { $ref: "#/components/responses/Unauthorized" } + + /v1/admin/moderation/content-images/{fileId}: + get: + security: + - moderationAdminKey: [] + tags: [ModerationAdmin] + summary: 관리자용 검토 대기 이미지 조회 + operationId: getPendingModerationContentImage + parameters: + - name: fileId + in: path + required: true + schema: + type: string + pattern: "^[a-fA-F0-9]{32}$" + - name: x-soundlog-admin-key + in: header + required: true + schema: { type: string } + responses: + "200": + description: 검토할 이미지 원본 + content: + image/jpeg: { schema: { type: string, format: binary } } + image/png: { schema: { type: string, format: binary } } + image/webp: { schema: { type: string, format: binary } } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + /v1/admin/moderation/content/{contentId}: + patch: + security: + - moderationAdminKey: [] + tags: [ModerationAdmin] + summary: 공개 이미지 승인 또는 거절 + operationId: reviewModerationContent + parameters: + - name: contentId + in: path + required: true + schema: { type: string } + - name: x-soundlog-admin-key + in: header + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [type, decision] + properties: + type: + type: string + enum: [moment_log, recap] + decision: + type: string + enum: [approved, rejected] + responses: + "200": { description: 검토 결과 } + "401": { $ref: "#/components/responses/Unauthorized" } + + /v1/admin/moderation/sweep: + post: + security: + - moderationAdminKey: [] + tags: [ModerationAdmin] + summary: 20시간 알림과 24시간 초과 신고 점검 + operationId: sweepModerationDeadlines + parameters: + - name: x-soundlog-admin-key + in: header + required: true + schema: { type: string } + responses: + "200": { description: 점검 결과 } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/travel-sessions: post: tags: @@ -1965,6 +2125,10 @@ components: type: http scheme: bearer bearerFormat: JWT + moderationAdminKey: + type: apiKey + in: header + name: x-soundlog-admin-key parameters: Lat: @@ -3084,6 +3248,7 @@ components: - title - placeName - ownerAlias + - isMine - location - trackTitle - artistName @@ -3106,6 +3271,8 @@ components: ownerAlias: type: string example: Soundlog 여행자 + isMine: + type: boolean location: $ref: "#/components/schemas/GeoPoint" trackTitle: @@ -3704,6 +3871,11 @@ components: - other targetUserId: type: string + targetType: + type: string + enum: [user, sound_pin, recap, travel_room_moment, travel_room_comment, mate_request] + targetContentId: + type: string targetPinId: type: string requestId: @@ -3712,6 +3884,22 @@ components: type: string maxLength: 500 + CommunityReportAcceptedResponse: + type: object + required: [data] + properties: + data: + type: object + required: [id, dueAt, notified] + properties: + id: + type: string + dueAt: + type: string + format: date-time + notified: + type: boolean + TravelRoomResponse: type: object required: @@ -3911,11 +4099,20 @@ components: allOf: - $ref: "#/components/schemas/LoginRequest" - type: object + required: + - termsAccepted + - termsVersion properties: displayName: type: string maxLength: 120 example: Soundlog User + termsAccepted: + type: boolean + const: true + termsVersion: + type: string + const: "2026-08-15" AuthUser: type: object diff --git a/package.json b/package.json index e26cbd3..bd4a594 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "db:migrate": "prisma migrate dev --schema prisma", "db:deploy": "prisma migrate deploy --schema prisma", "db:seed": "tsx prisma/seed.ts", - "db:seed:public": "tsx prisma/seed.ts --public-catalog" + "db:seed:public": "tsx prisma/seed.ts --public-catalog", + "db:seed:review": "tsx prisma/seed-review.ts" }, "prisma": { "seed": "tsx prisma/seed.ts" diff --git a/prisma/migrations/20260815000000_app_review_moderation/migration.sql b/prisma/migrations/20260815000000_app_review_moderation/migration.sql new file mode 100644 index 0000000..864145c --- /dev/null +++ b/prisma/migrations/20260815000000_app_review_moderation/migration.sql @@ -0,0 +1,40 @@ +ALTER TABLE "User" +ADD COLUMN "termsAcceptedAt" TIMESTAMP(3), +ADD COLUMN "termsVersion" TEXT, +ADD COLUMN "moderationStatus" TEXT NOT NULL DEFAULT 'active', +ADD COLUMN "suspendedAt" TIMESTAMP(3), +ADD COLUMN "suspensionReason" TEXT; + +ALTER TABLE "MomentLog" +ADD COLUMN "moderationStatus" TEXT NOT NULL DEFAULT 'approved', +ADD COLUMN "moderationReviewedAt" TIMESTAMP(3), +ADD COLUMN "moderationReviewedBy" TEXT; + +ALTER TABLE "Recap" +ADD COLUMN "moderationStatus" TEXT NOT NULL DEFAULT 'approved', +ADD COLUMN "moderationReviewedAt" TIMESTAMP(3), +ADD COLUMN "moderationReviewedBy" TEXT; + +ALTER TABLE "TravelRoomMoment" +ADD COLUMN "moderationStatus" TEXT NOT NULL DEFAULT 'approved'; + +ALTER TABLE "TravelRoomMomentComment" +ADD COLUMN "moderationStatus" TEXT NOT NULL DEFAULT 'approved'; + +ALTER TABLE "CommunityReport" +ADD COLUMN "targetType" TEXT NOT NULL DEFAULT 'user', +ADD COLUMN "targetContentId" TEXT, +ADD COLUMN "contentSnapshot" JSONB, +ADD COLUMN "status" TEXT NOT NULL DEFAULT 'pending', +ADD COLUMN "dueAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '24 hours'), +ADD COLUMN "reminderSentAt" TIMESTAMP(3), +ADD COLUMN "overdueSentAt" TIMESTAMP(3), +ADD COLUMN "resolvedAt" TIMESTAMP(3), +ADD COLUMN "resolvedBy" TEXT, +ADD COLUMN "resolution" TEXT; + +CREATE INDEX "CommunityReport_status_dueAt_idx" +ON "CommunityReport"("status", "dueAt"); + +CREATE INDEX "CommunityReport_targetType_targetContentId_idx" +ON "CommunityReport"("targetType", "targetContentId"); diff --git a/prisma/models/auth.prisma b/prisma/models/auth.prisma index f0c41e4..719068d 100644 --- a/prisma/models/auth.prisma +++ b/prisma/models/auth.prisma @@ -4,6 +4,11 @@ model User { providerUserId String displayName String? passwordHash String? + termsAcceptedAt DateTime? + termsVersion String? + moderationStatus String @default("active") + suspendedAt DateTime? + suspensionReason String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt profile UserProfile? diff --git a/prisma/models/community.prisma b/prisma/models/community.prisma index 5572818..92e8392 100644 --- a/prisma/models/community.prisma +++ b/prisma/models/community.prisma @@ -33,6 +33,7 @@ model TravelRoomMoment { placeName String? note String? status String @default("candidate") + moderationStatus String @default("approved") trackSnapshot Json? createdAt DateTime @default(now()) room TravelRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) @@ -45,6 +46,7 @@ model TravelRoomMomentComment { momentId String userId String body String + moderationStatus String @default("approved") createdAt DateTime @default(now()) moment TravelRoomMoment @relation(fields: [momentId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -100,10 +102,23 @@ model CommunityReport { targetUserId String? targetPinId String? requestId String? + targetType String @default("user") + targetContentId String? + contentSnapshot Json? reason String details String? + status String @default("pending") + dueAt DateTime + reminderSentAt DateTime? + overdueSentAt DateTime? + resolvedAt DateTime? + resolvedBy String? + resolution String? createdAt DateTime @default(now()) reporter User @relation("CommunityReporter", fields: [reporterId], references: [id], onDelete: Cascade) targetUser User? @relation("CommunityReportedUser", fields: [targetUserId], references: [id], onDelete: SetNull) mateRequest TravelMateRequest? @relation(fields: [requestId], references: [id], onDelete: SetNull) + + @@index([status, dueAt]) + @@index([targetType, targetContentId]) } diff --git a/prisma/models/recap.prisma b/prisma/models/recap.prisma index 27129d4..8960737 100644 --- a/prisma/models/recap.prisma +++ b/prisma/models/recap.prisma @@ -17,6 +17,9 @@ model Recap { routePoints Json? templateId String @default("album") visibility String @default("private") + moderationStatus String @default("approved") + moderationReviewedAt DateTime? + moderationReviewedBy String? lat Float? lng Float? user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/prisma/models/travel.prisma b/prisma/models/travel.prisma index 20ba171..5d55c4a 100644 --- a/prisma/models/travel.prisma +++ b/prisma/models/travel.prisma @@ -32,6 +32,9 @@ model MomentLog { moodTags String[] source String visibility String @default("private") + moderationStatus String @default("approved") + moderationReviewedAt DateTime? + moderationReviewedBy String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) } diff --git a/prisma/seed-review.ts b/prisma/seed-review.ts new file mode 100644 index 0000000..08cbc61 --- /dev/null +++ b/prisma/seed-review.ts @@ -0,0 +1,264 @@ +import bcrypt from 'bcrypt'; +import { PrismaClient } from '@prisma/client'; + +import { CURRENT_TERMS_VERSION } from '../src/constants/legal.constants.js'; +import { seedDemoCommunity, seedPublicCatalog } from './seed.js'; + +const prisma = new PrismaClient(); + +const reviewEmail = process.env.APP_REVIEW_EMAIL?.trim().toLowerCase(); +const reviewPassword = process.env.APP_REVIEW_PASSWORD; + +async function seedReviewExperience() { + if (!reviewEmail || !reviewPassword) { + throw new Error('APP_REVIEW_EMAIL and APP_REVIEW_PASSWORD are required.'); + } + if (reviewPassword.length < 8) { + throw new Error('APP_REVIEW_PASSWORD must be at least 8 characters.'); + } + + await seedPublicCatalog(); + await seedDemoCommunity(); + + const demoUser = await prisma.user.findUniqueOrThrow({ + where: { + provider_providerUserId: { + provider: 'seed', + providerUserId: 'demo-minji', + }, + }, + }); + const secondDemoUser = await prisma.user.findUniqueOrThrow({ + where: { + provider_providerUserId: { + provider: 'seed', + providerUserId: 'demo-seoyeon', + }, + }, + }); + const reviewUser = await prisma.user.upsert({ + where: { + provider_providerUserId: { + provider: 'email', + providerUserId: reviewEmail, + }, + }, + update: { + displayName: 'Soundlog Reviewer', + moderationStatus: 'active', + passwordHash: await bcrypt.hash(reviewPassword, 12), + suspendedAt: null, + suspensionReason: null, + termsAcceptedAt: new Date(), + termsVersion: CURRENT_TERMS_VERSION, + }, + create: { + displayName: 'Soundlog Reviewer', + moderationStatus: 'active', + passwordHash: await bcrypt.hash(reviewPassword, 12), + provider: 'email', + providerUserId: reviewEmail, + termsAcceptedAt: new Date(), + termsVersion: CURRENT_TERMS_VERSION, + }, + }); + + await prisma.userProfile.upsert({ + where: { userId: reviewUser.id }, + update: { + companionType: 'solo', + completedOnboarding: true, + dislikedArtists: [], + locationRecommendationEnabled: true, + preferredGenres: ['K-POP', '인디'], + preferredMoods: ['설레는', '잔잔한'], + travelStyles: ['산책', '야경'], + }, + create: { + companionType: 'solo', + completedOnboarding: true, + dislikedArtists: [], + locationRecommendationEnabled: true, + preferredGenres: ['K-POP', '인디'], + preferredMoods: ['설레는', '잔잔한'], + travelStyles: ['산책', '야경'], + userId: reviewUser.id, + }, + }); + await prisma.musicPlatform.upsert({ + where: { userId: reviewUser.id }, + update: { connected: false, providerUserId: null, selectedPlatformId: 'none' }, + create: { connected: false, selectedPlatformId: 'none', userId: reviewUser.id }, + }); + + await prisma.$transaction([ + prisma.communityBlock.deleteMany({ + where: { + OR: [ + { blockerId: reviewUser.id }, + { blockedUserId: reviewUser.id }, + ], + }, + }), + prisma.communityReport.deleteMany({ + where: { + OR: [ + { reporterId: reviewUser.id }, + { targetUserId: reviewUser.id }, + ], + }, + }), + prisma.travelMateRequest.deleteMany({ + where: { + OR: [ + { requesterId: reviewUser.id }, + { targetUserId: reviewUser.id }, + ], + }, + }), + ]); + + const reviewTrack = await prisma.track.findUniqueOrThrow({ where: { id: 'seoul-city' } }); + const trackSnapshot = { + albumImageUrl: reviewTrack.albumImageUrl, + artist: reviewTrack.artist, + externalUrl: reviewTrack.externalUrl, + fallbackColor: reviewTrack.fallbackColor, + id: reviewTrack.id, + platformUrls: reviewTrack.platformUrls, + title: reviewTrack.title, + }; + + const reviewPins = [ + { + id: 'app-review-sound-pin-namsan', + lat: 37.5512, + lng: 126.9882, + moodTags: ['설레는', '감성적인'], + placeName: '남산서울타워', + userId: demoUser.id, + }, + { + id: 'app-review-sound-pin-haebangchon', + lat: 37.5436, + lng: 126.9878, + moodTags: ['잔잔한', '야경'], + placeName: '해방촌', + userId: secondDemoUser.id, + }, + ]; + for (const pin of reviewPins) { + await prisma.soundMapPin.upsert({ + where: { userId: pin.userId }, + update: { + approxLat: Number(pin.lat.toFixed(3)), + approxLng: Number(pin.lng.toFixed(3)), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + lat: pin.lat, + lng: pin.lng, + moodTags: pin.moodTags, + placeName: pin.placeName, + trackSnapshot, + travelMode: 'walk', + visibility: 'nearby', + }, + create: { + approxLat: Number(pin.lat.toFixed(3)), + approxLng: Number(pin.lng.toFixed(3)), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + id: pin.id, + lat: pin.lat, + lng: pin.lng, + moodTags: pin.moodTags, + placeName: pin.placeName, + trackSnapshot, + travelMode: 'walk', + userId: pin.userId, + visibility: 'nearby', + }, + }); + } + + await prisma.travelRoom.upsert({ + where: { id: 'app-review-travel-room' }, + update: { ownerId: demoUser.id, title: '서울 야경 같이 듣기' }, + create: { + id: 'app-review-travel-room', + inviteCode: 'REVIEW26', + ownerId: demoUser.id, + title: '서울 야경 같이 듣기', + }, + }); + await prisma.travelRoomMember.upsert({ + where: { + roomId_userId: { roomId: 'app-review-travel-room', userId: demoUser.id }, + }, + update: { displayName: '민지', role: 'owner' }, + create: { + displayName: '민지', + role: 'owner', + roomId: 'app-review-travel-room', + userId: demoUser.id, + }, + }); + await prisma.travelRoomMember.upsert({ + where: { + roomId_userId: { roomId: 'app-review-travel-room', userId: reviewUser.id }, + }, + update: { displayName: 'Soundlog Reviewer', role: 'member' }, + create: { + displayName: 'Soundlog Reviewer', + role: 'member', + roomId: 'app-review-travel-room', + userId: reviewUser.id, + }, + }); + await prisma.travelRoomMoment.upsert({ + where: { id: 'app-review-room-moment' }, + update: { + moderationStatus: 'approved', + note: '남산에서 함께 들은 오늘의 곡이에요.', + placeName: '남산서울타워', + roomId: 'app-review-travel-room', + status: 'accepted', + trackSnapshot, + userId: demoUser.id, + }, + create: { + id: 'app-review-room-moment', + moderationStatus: 'approved', + note: '남산에서 함께 들은 오늘의 곡이에요.', + placeName: '남산서울타워', + roomId: 'app-review-travel-room', + status: 'accepted', + trackSnapshot, + userId: demoUser.id, + }, + }); + await prisma.travelRoomMomentComment.upsert({ + where: { id: 'app-review-room-comment' }, + update: { + body: '야경과 정말 잘 어울리는 노래였어요.', + moderationStatus: 'approved', + userId: demoUser.id, + }, + create: { + body: '야경과 정말 잘 어울리는 노래였어요.', + id: 'app-review-room-comment', + moderationStatus: 'approved', + momentId: 'app-review-room-moment', + userId: demoUser.id, + }, + }); + + console.log(`App review experience seeded for ${reviewEmail}.`); +} + +seedReviewExperience() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/check-live-e2e.mjs b/scripts/check-live-e2e.mjs index 721501e..0fad420 100644 --- a/scripts/check-live-e2e.mjs +++ b/scripts/check-live-e2e.mjs @@ -73,6 +73,8 @@ async function registerUser(label) { displayName: `Live ${label}`, email, password, + termsAccepted: true, + termsVersion: '2026-08-15', }, expectedStatus: 201, method: 'POST', diff --git a/scripts/check-production-env.mjs b/scripts/check-production-env.mjs index 6bd5310..acf7886 100644 --- a/scripts/check-production-env.mjs +++ b/scripts/check-production-env.mjs @@ -5,6 +5,10 @@ const required = [ 'JWT_SECRET', 'CLIENT_URLS', 'UPLOAD_PUBLIC_BASE_URL', + 'MODERATION_ADMIN_KEY', + 'MODERATION_ALERT_MODE', + 'APP_REVIEW_EMAIL', + 'APP_REVIEW_PASSWORD', ]; const errors = []; @@ -76,6 +80,25 @@ if ((process.env.JWT_SECRET ?? '').length < 32) { addWarning('JWT_SECRET is shorter than 32 characters. Use a long random secret in production.'); } +if ((process.env.MODERATION_ADMIN_KEY ?? '').length < 32) { + addError('MODERATION_ADMIN_KEY must be at least 32 characters in production.'); +} + +if (!['cloud_logging', 'webhook'].includes(process.env.MODERATION_ALERT_MODE)) { + addError('MODERATION_ALERT_MODE must be cloud_logging or webhook.'); +} + +if ( + process.env.MODERATION_ALERT_MODE === 'webhook' && + !isHttpsUrl(process.env.MODERATION_ALERT_WEBHOOK_URL) +) { + addError('Webhook alert mode requires an HTTPS MODERATION_ALERT_WEBHOOK_URL.'); +} + +if ((process.env.APP_REVIEW_PASSWORD ?? '').length < 8) { + addError('APP_REVIEW_PASSWORD must be at least 8 characters.'); +} + if (!process.env.TOUR_API_SERVICE_KEY) { addWarning('TOUR_API_SERVICE_KEY is missing. Tour nearby-place API will fall back to seed/mock behavior.'); } diff --git a/scripts/check-public-api-contract.mjs b/scripts/check-public-api-contract.mjs index d6853d0..c8d3648 100644 --- a/scripts/check-public-api-contract.mjs +++ b/scripts/check-public-api-contract.mjs @@ -70,6 +70,8 @@ async function createContractSession() { displayName: 'Soundlog Contract Check', email: `soundlog-contract-${uniqueId}@example.com`, password: 'SoundlogContract!2026', + termsAccepted: true, + termsVersion: '2026-08-15', }, method: 'POST', }); diff --git a/scripts/start-container.sh b/scripts/start-container.sh index c24d24d..97a932c 100644 --- a/scripts/start-container.sh +++ b/scripts/start-container.sh @@ -2,6 +2,7 @@ set -eu +node scripts/check-production-env.mjs ./node_modules/.bin/prisma migrate deploy node dist/prisma/seed.js --public-catalog exec node dist/src/server.js diff --git a/src/config/env.ts b/src/config/env.ts index 2b3b695..aee605a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -22,6 +22,13 @@ const envSchema = z.object({ z.string().url().optional(), ), ML_RECOMMENDATION_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), + MODERATION_ADMIN_KEY: z.string().min(32).optional(), + MODERATION_ALERT_MODE: z.enum(['cloud_logging', 'webhook']).default('cloud_logging'), + MODERATION_ALERT_WEBHOOK_URL: z.preprocess( + (value) => (value === '' ? undefined : value), + z.string().url().optional(), + ), + MODERATION_SWEEP_INTERVAL_MS: z.coerce.number().int().positive().default(15 * 60 * 1000), MOMENT_PHOTO_MAX_FILE_SIZE_MB: z.coerce.number().int().positive().default(10), NODE_ENV: z.string().default('development'), PORT: z.coerce.number().int().positive().default(4000), diff --git a/src/constants/error.constants.ts b/src/constants/error.constants.ts index 1e844ce..ca2e033 100644 --- a/src/constants/error.constants.ts +++ b/src/constants/error.constants.ts @@ -1,5 +1,6 @@ export const ERROR_CODES = { BAD_REQUEST: 'BAD_REQUEST', + CONTENT_REJECTED: 'CONTENT_REJECTED', DATABASE_ERROR: 'DATABASE_ERROR', FORBIDDEN: 'FORBIDDEN', INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', @@ -9,6 +10,8 @@ export const ERROR_CODES = { export const ERROR_MESSAGES = { AUTH_REQUIRED: '인증이 필요합니다.', + ACCOUNT_SUSPENDED: '커뮤니티 운영 정책 위반으로 이용이 제한된 계정입니다.', + CONTENT_REJECTED: '커뮤니티 운영 정책에 맞지 않는 내용이 포함되어 있습니다. 내용을 수정해 주세요.', DATABASE_ERROR: '데이터 처리 중 오류가 발생했습니다.', ENDED_TRAVEL_SESSION_CANNOT_ACTIVATE: '종료된 여행 세션은 다시 활성화할 수 없습니다.', FILE_UPLOAD_INVALID: '파일 업로드 요청이 올바르지 않습니다.', @@ -19,6 +22,7 @@ export const ERROR_MESSAGES = { INVALID_REFRESH_TOKEN: 'refresh token이 유효하지 않습니다.', INVALID_REQUEST: '요청 값이 올바르지 않습니다.', INVALID_TOKEN: '유효하지 않은 토큰입니다.', + MODERATION_ADMIN_NOT_CONFIGURED: '신고 운영 관리자 인증이 설정되지 않았습니다.', PHOTO_REQUIRED: 'photo 파일이 필요합니다.', PLAYLIST_NOT_FOUND: '플레이리스트를 찾을 수 없습니다.', MOMENT_LOG_NOT_FOUND: 'Moment Log를 찾을 수 없습니다.', diff --git a/src/constants/legal.constants.ts b/src/constants/legal.constants.ts new file mode 100644 index 0000000..f34c2b9 --- /dev/null +++ b/src/constants/legal.constants.ts @@ -0,0 +1 @@ +export const CURRENT_TERMS_VERSION = '2026-08-15'; diff --git a/src/controllers/community.controller.ts b/src/controllers/community.controller.ts index 21e559e..ba71f4b 100644 --- a/src/controllers/community.controller.ts +++ b/src/controllers/community.controller.ts @@ -136,7 +136,6 @@ export const communityController = { async reportCommunityTarget(req: Request, res: Response) { const user = requireUser(req); - await apiService.reportCommunityTarget(user.id, req.body); - res.status(202).json(acceptedResponse()); + res.status(202).json(dataResponse(await apiService.reportCommunityTarget(user.id, req.body))); }, }; diff --git a/src/controllers/index.ts b/src/controllers/index.ts index fdc24b4..dc3187d 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -4,6 +4,7 @@ export { devDbTestController } from './dev-db-test.controller.js'; export { homeController } from './home.controller.js'; export { libraryController } from './library.controller.js'; export { meController } from './me.controller.js'; +export { moderationController } from './moderation.controller.js'; export { momentLogController } from './moment-log.controller.js'; export { playlistController } from './playlist.controller.js'; export { recapController } from './recap.controller.js'; diff --git a/src/controllers/moderation.controller.ts b/src/controllers/moderation.controller.ts new file mode 100644 index 0000000..a2d3fd2 --- /dev/null +++ b/src/controllers/moderation.controller.ts @@ -0,0 +1,53 @@ +import type { Request, Response } from 'express'; + +import { apiService } from '../services/api.service.js'; +import { uploadFileService } from '../services/upload-file.service.js'; +import { notFound } from '../utils/http-error.js'; +import { dataResponse } from '../utils/response.js'; + +export const moderationController = { + async listPendingContent(req: Request, res: Response) { + res.json(dataResponse( + await apiService.listPendingModerationContent(Number(req.query.limit ?? 50)), + )); + }, + + async reviewContent(req: Request, res: Response) { + res.json(dataResponse(await apiService.reviewModerationContent({ + contentId: String(req.params.contentId), + reviewedBy: req.header('x-soundlog-admin-actor')?.trim() || 'admin', + ...req.body, + }))); + }, + + async getPendingContentImage(req: Request, res: Response) { + const resolved = await uploadFileService.resolveUploadedFileForModeration( + String(req.params.fileId), + ); + if (!resolved) throw notFound(); + + const contentType = await uploadFileService.detectImageContentType(resolved.absolutePath); + if (!contentType) throw notFound(); + + res.type(contentType); + res.sendFile(resolved.absolutePath); + }, + + async listReports(req: Request, res: Response) { + res.json(dataResponse(await apiService.listModerationReports(req.query as never))); + }, + + async resolveReport(req: Request, res: Response) { + res.json(dataResponse(await apiService.resolveModerationReport( + String(req.params.reportId), + { + ...req.body, + resolvedBy: req.header('x-soundlog-admin-actor')?.trim() || 'admin', + }, + ))); + }, + + async sweepDeadlines(_req: Request, res: Response) { + res.json(dataResponse(await apiService.sweepModerationDeadlines())); + }, +}; diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index d81717d..8d6f3b2 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -2,10 +2,11 @@ import type { NextFunction, Request, Response } from 'express'; import { env } from '../config/env.js'; import { ERROR_MESSAGES } from '../constants/error.constants.js'; +import { CURRENT_TERMS_VERSION } from '../constants/legal.constants.js'; import { prisma } from '../config/prisma.js'; import { defaultUser } from '../data/seed-data.js'; import { mockDb } from '../mock/mock-db.js'; -import { unauthorized } from '../utils/http-error.js'; +import { HttpError, unauthorized } from '../utils/http-error.js'; import { verifyAccessToken } from '../utils/tokens.js'; export async function authMiddleware(req: Request, _res: Response, next: NextFunction) { @@ -50,6 +51,13 @@ export async function authMiddleware(req: Request, _res: Response, next: NextFun throw unauthorized(ERROR_MESSAGES.INVALID_TOKEN); } + if ( + passwordUser && + (!passwordUser.termsAcceptedAt || passwordUser.termsVersion !== CURRENT_TERMS_VERSION) + ) { + throw unauthorized('최신 이용약관 동의가 필요합니다. 다시 로그인해 주세요.'); + } + req.user = passwordUser ? { id: passwordUser.id, @@ -69,8 +77,11 @@ export async function authMiddleware(req: Request, _res: Response, next: NextFun where: { id: userId }, select: { id: true, + moderationStatus: true, provider: true, providerUserId: true, + termsAcceptedAt: true, + termsVersion: true, }, }); @@ -78,9 +89,20 @@ export async function authMiddleware(req: Request, _res: Response, next: NextFun throw unauthorized(ERROR_MESSAGES.INVALID_TOKEN); } + if (user.moderationStatus === 'suspended') { + throw unauthorized(ERROR_MESSAGES.ACCOUNT_SUSPENDED); + } + + if (!user.termsAcceptedAt || user.termsVersion !== CURRENT_TERMS_VERSION) { + throw unauthorized('최신 이용약관 동의가 필요합니다. 다시 로그인해 주세요.'); + } + req.user = user; next(); - } catch { + } catch (error) { + if (error instanceof HttpError) { + throw error; + } throw unauthorized(ERROR_MESSAGES.INVALID_TOKEN); } } diff --git a/src/middlewares/moderation-admin.middleware.ts b/src/middlewares/moderation-admin.middleware.ts new file mode 100644 index 0000000..3af0092 --- /dev/null +++ b/src/middlewares/moderation-admin.middleware.ts @@ -0,0 +1,26 @@ +import crypto from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; + +import { env } from '../config/env.js'; +import { ERROR_MESSAGES } from '../constants/error.constants.js'; +import { unauthorized } from '../utils/http-error.js'; + +function matchesSecret(candidate: string, secret: string) { + const candidateBuffer = Buffer.from(candidate); + const secretBuffer = Buffer.from(secret); + return candidateBuffer.length === secretBuffer.length && + crypto.timingSafeEqual(candidateBuffer, secretBuffer); +} + +export function moderationAdminMiddleware(req: Request, _res: Response, next: NextFunction) { + if (!env.MODERATION_ADMIN_KEY) { + throw unauthorized(ERROR_MESSAGES.MODERATION_ADMIN_NOT_CONFIGURED); + } + + const candidate = req.header('x-soundlog-admin-key') ?? ''; + if (!matchesSecret(candidate, env.MODERATION_ADMIN_KEY)) { + throw unauthorized(); + } + + next(); +} diff --git a/src/middlewares/rate-limit.middleware.ts b/src/middlewares/rate-limit.middleware.ts index 4142950..00afcba 100644 --- a/src/middlewares/rate-limit.middleware.ts +++ b/src/middlewares/rate-limit.middleware.ts @@ -75,3 +75,13 @@ export const authIpRateLimitMiddleware = rateLimit({ standardHeaders: true, windowMs: env.AUTH_RATE_LIMIT_IP_WINDOW_MS, }); + +export const communitySafetyRateLimitMiddleware = rateLimit({ + handler, + keyGenerator: (req) => req.user?.id ?? ipKeyGeneratorForRequest(req), + legacyHeaders: false, + limit: 20, + skip, + standardHeaders: true, + windowMs: 60 * 60 * 1000, +}); diff --git a/src/mock/mock-db.ts b/src/mock/mock-db.ts index 2f3b065..08164f3 100644 --- a/src/mock/mock-db.ts +++ b/src/mock/mock-db.ts @@ -36,6 +36,8 @@ type MockMomentLog = { trackSnapshot?: MockTrack; travelMode?: string; visibility: 'private' | 'public'; + moderationStatus: 'approved' | 'pending' | 'rejected'; + userId: string; }; type MockLibraryTrackState = { @@ -69,6 +71,7 @@ type MockRecap = { title: string; userId: string; visibility: 'private' | 'public'; + moderationStatus: 'approved' | 'pending' | 'rejected'; }; type MockTravelSession = { @@ -100,6 +103,9 @@ type MockPasswordUser = { email: string; id: string; passwordHash: string; + termsAcceptedAt: Date; + termsVersion: string; + moderationStatus: 'active' | 'suspended'; }; type MockTravelRoom = { @@ -195,6 +201,8 @@ function createMockDb() { id: 'mock-user-local', ...defaultUser, displayName: 'Local Soundlog Mock User', + termsAcceptedAt: undefined as Date | undefined, + termsVersion: undefined as string | undefined, }, profile: { companionType: 'friends', @@ -257,6 +265,8 @@ function createMockDb() { trackSnapshot: trackById.get(log.trackId), travelMode: log.travelMode, visibility: log.visibility, + moderationStatus: 'approved' as const, + userId: 'mock-user-local', })) as MockMomentLog[], recommendationEvents: [] as Array<{ context: Record; @@ -291,6 +301,7 @@ function createMockDb() { routePoints: routePoints ? [...routePoints] : undefined, templateId: recap.templateId ?? 'album', visibility: recap.visibility ?? 'private', + moderationStatus: 'approved' as const, lat: recap.lat, lng: recap.lng, }; @@ -324,11 +335,15 @@ function createMockDb() { communityReports: [] as Array<{ createdAt: Date; details?: string; + dueAt: Date; id: string; reason: string; reporterId: string; requestId?: string; + status: string; + targetContentId?: string; targetPinId?: string; + targetType: string; targetUserId?: string; }>, idempotencyRecords: [] as Array<{ diff --git a/src/routes/index.ts b/src/routes/index.ts index 7528324..4971b3a 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -7,6 +7,7 @@ import { homeController, libraryController, meController, + moderationController, momentLogController, playlistController, recapController, @@ -19,9 +20,11 @@ import { import { env } from '../config/env.js'; import { asyncHandler } from '../utils/async-handler.js'; import { authMiddleware } from '../middlewares/auth.middleware.js'; +import { moderationAdminMiddleware } from '../middlewares/moderation-admin.middleware.js'; import { authAccountRateLimitMiddleware, authIpRateLimitMiddleware, + communitySafetyRateLimitMiddleware, } from '../middlewares/rate-limit.middleware.js'; import { momentPhotoUpload } from '../middlewares/upload.middleware.js'; import { validate } from '../middlewares/validate.middleware.js'; @@ -32,6 +35,7 @@ import { homeValidators, libraryValidators, meValidators, + moderationValidators, momentLogValidators, playlistValidators, recapValidators, @@ -431,15 +435,58 @@ export function createApiRouter() { router.post( '/v1/community/blocks', authMiddleware, + communitySafetyRateLimitMiddleware, validate({ body: communityValidators.blockBody }), asyncHandler(communityController.blockCommunityUser), ); router.post( '/v1/community/reports', authMiddleware, + communitySafetyRateLimitMiddleware, validate({ body: communityValidators.reportBody }), asyncHandler(communityController.reportCommunityTarget), ); + router.get( + '/v1/admin/moderation/reports', + moderationAdminMiddleware, + validate({ query: moderationValidators.listQuery }), + asyncHandler(moderationController.listReports), + ); + router.get( + '/v1/admin/moderation/content', + moderationAdminMiddleware, + validate({ query: moderationValidators.contentListQuery }), + asyncHandler(moderationController.listPendingContent), + ); + router.get( + '/v1/admin/moderation/content-images/:fileId', + moderationAdminMiddleware, + validate({ params: moderationValidators.contentImageParams }), + asyncHandler(moderationController.getPendingContentImage), + ); + router.patch( + '/v1/admin/moderation/content/:contentId', + moderationAdminMiddleware, + validate({ + params: moderationValidators.contentParams, + body: moderationValidators.contentReviewBody, + }), + asyncHandler(moderationController.reviewContent), + ); + router.patch( + '/v1/admin/moderation/reports/:reportId', + moderationAdminMiddleware, + validate({ + params: moderationValidators.reportParams, + body: moderationValidators.resolveBody, + }), + asyncHandler(moderationController.resolveReport), + ); + router.post( + '/v1/admin/moderation/sweep', + moderationAdminMiddleware, + asyncHandler(moderationController.sweepDeadlines), + ); router.post( '/v1/travel-sessions', diff --git a/src/routes/legal.router.ts b/src/routes/legal.router.ts index 4a41565..f9c87bc 100644 --- a/src/routes/legal.router.ts +++ b/src/routes/legal.router.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; const SUPPORT_EMAIL = 'support@soundlog.shop'; -const EFFECTIVE_DATE = '2026.08.02'; +const EFFECTIVE_DATE = '2026.08.15'; type LegalSection = { body: string; @@ -80,7 +80,11 @@ const termsPage: LegalPage = { }, { title: '제한 사항', - body: '타인의 권리를 침해하는 콘텐츠, 불법적인 목적의 이용, 서비스 안정성을 해치는 행위는 허용되지 않습니다. 필요한 경우 서비스 이용이 제한될 수 있습니다.', + body: 'Soundlog는 불쾌하거나 폭력적이거나 혐오적이거나 성적인 콘텐츠를 용납하지 않습니다. 타인을 괴롭히거나 사칭하거나 위협하거나 스팸을 보내는 행위와 불법적인 목적의 이용도 허용하지 않습니다. 위반 콘텐츠는 즉시 숨김 또는 삭제될 수 있으며 위반 사용자는 서비스 이용이 정지될 수 있습니다.', + }, + { + title: '신고와 차단', + body: `사용자는 앱에서 부적절한 콘텐츠를 신고하고 해당 사용자를 차단할 수 있습니다. 차단한 사용자의 콘텐츠는 즉시 피드와 지도에서 숨겨집니다. 신고는 ${SUPPORT_EMAIL}으로도 접수할 수 있으며 Soundlog는 신고 접수 후 24시간 안에 검토하고 필요한 콘텐츠 삭제와 사용자 제재를 진행합니다.`, }, { title: '문의와 변경', diff --git a/src/server.ts b/src/server.ts index 68c4cde..86a319a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ import { createApp } from './app.js'; import { env } from './config/env.js'; import { prisma } from './config/prisma.js'; +import { contentModerationService } from './services/content-moderation.service.js'; const app = createApp(); @@ -8,7 +9,15 @@ const server = app.listen(env.PORT, () => { console.log(`Soundlog API server listening on http://localhost:${env.PORT}`); }); +const moderationSweepTimer = setInterval(() => { + void contentModerationService.sweepReportDeadlines().catch((error) => { + console.error('Moderation deadline sweep failed', error); + }); +}, env.MODERATION_SWEEP_INTERVAL_MS); +moderationSweepTimer.unref(); + function shutdown() { + clearInterval(moderationSweepTimer); server.close(() => { void prisma.$disconnect().finally(() => process.exit(0)); }); @@ -16,4 +25,3 @@ function shutdown() { process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); - diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 9c50e1d..1e33036 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -2,6 +2,7 @@ import bcrypt from 'bcrypt'; import { env } from '../config/env.js'; import { ERROR_MESSAGES } from '../constants/error.constants.js'; +import { CURRENT_TERMS_VERSION } from '../constants/legal.constants.js'; import { prisma } from '../config/prisma.js'; import { mockDb } from '../mock/mock-db.js'; import { @@ -10,11 +11,14 @@ import { signAccessToken, } from '../utils/tokens.js'; import { badRequest, unauthorized } from '../utils/http-error.js'; +import { assertUserTextAllowed, inspectUserText } from './content-moderation.service.js'; type EmailPasswordAuthInput = { displayName?: string; email: string; password: string; + termsAccepted?: boolean; + termsVersion?: string; }; const FIRST_PARTY_PROVIDER = 'email'; @@ -31,7 +35,13 @@ function getDefaultDisplayName(email: string) { function getEmailDisplayName(input: EmailPasswordAuthInput) { const displayName = input.displayName?.trim(); - return displayName || getDefaultDisplayName(normalizeEmail(input.email)); + if (displayName) { + assertUserTextAllowed({ displayName }); + return displayName; + } + + const fallback = getDefaultDisplayName(normalizeEmail(input.email)); + return inspectUserText(fallback).allowed ? fallback : 'Soundlog User'; } export const authService = { @@ -50,7 +60,10 @@ export const authService = { }, select: { id: true, + moderationStatus: true, passwordHash: true, + termsAcceptedAt: true, + termsVersion: true, }, }); @@ -64,10 +77,32 @@ export const authService = { throw unauthorized(ERROR_MESSAGES.INVALID_EMAIL_OR_PASSWORD); } + if (user.moderationStatus === 'suspended') { + throw unauthorized(ERROR_MESSAGES.ACCOUNT_SUSPENDED); + } + + if (!user.termsAcceptedAt || user.termsVersion !== CURRENT_TERMS_VERSION) { + if (!input.termsAccepted || input.termsVersion !== CURRENT_TERMS_VERSION) { + throw badRequest('최신 이용약관에 동의해야 로그인할 수 있습니다.'); + } + await prisma.user.update({ + where: { id: user.id }, + data: { + termsAcceptedAt: new Date(), + termsVersion: CURRENT_TERMS_VERSION, + }, + }); + } + return createTokenPair(user.id, false); }, async register(input: EmailPasswordAuthInput) { + if (!input.termsAccepted || input.termsVersion !== CURRENT_TERMS_VERSION) { + throw badRequest('현재 이용약관에 동의해야 가입할 수 있습니다.'); + } + const displayName = getEmailDisplayName(input); + if (env.USE_MOCK_DB) { return registerMockEmailUser(input); } @@ -90,10 +125,12 @@ export const authService = { const passwordHash = await bcrypt.hash(input.password, PASSWORD_SALT_ROUNDS); const user = await prisma.user.create({ data: { - displayName: getEmailDisplayName(input), + displayName, passwordHash, provider: FIRST_PARTY_PROVIDER, providerUserId: email, + termsAcceptedAt: new Date(), + termsVersion: input.termsVersion, profile: { create: { locationRecommendationEnabled: true, @@ -179,6 +216,17 @@ export const authService = { }; async function createTokenPair(userId: string, isNewUser: boolean) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { moderationStatus: true, termsAcceptedAt: true, termsVersion: true }, + }); + if (!user || user.moderationStatus === 'suspended') { + throw unauthorized(ERROR_MESSAGES.ACCOUNT_SUSPENDED); + } + if (!user.termsAcceptedAt || user.termsVersion !== CURRENT_TERMS_VERSION) { + throw unauthorized('최신 이용약관 동의가 필요합니다. 다시 로그인해 주세요.'); + } + const refreshToken = createRefreshToken(); await prisma.refreshToken.create({ @@ -228,6 +276,8 @@ async function getUserDto(userId: string) { id: true, provider: true, providerUserId: true, + termsAcceptedAt: true, + termsVersion: true, }, }); @@ -236,6 +286,8 @@ async function getUserDto(userId: string) { email: user.provider === FIRST_PARTY_PROVIDER ? user.providerUserId : undefined, id: user.id, provider: user.provider, + termsAcceptedAt: user.termsAcceptedAt?.toISOString(), + termsVersion: user.termsVersion ?? undefined, }; } @@ -276,15 +328,25 @@ function mockUserToDto() { : undefined, id: mockDb.user.id, provider: mockDb.user.provider, + termsAcceptedAt: mockDb.user.termsAcceptedAt?.toISOString(), + termsVersion: mockDb.user.termsVersion, }; } -function setMockEmailUser(user: { displayName: string; email: string; id: string }) { +function setMockEmailUser(user: { + displayName: string; + email: string; + id: string; + termsAcceptedAt: Date; + termsVersion: string; +}) { Object.assign(mockDb.user, { displayName: user.displayName, id: user.id, provider: FIRST_PARTY_PROVIDER, providerUserId: user.email, + termsAcceptedAt: user.termsAcceptedAt, + termsVersion: user.termsVersion, }); } @@ -301,6 +363,9 @@ async function registerMockEmailUser(input: EmailPasswordAuthInput) { email, id: `mock-user-email-${hashToken(email).slice(0, 12)}`, passwordHash: await bcrypt.hash(input.password, PASSWORD_SALT_ROUNDS), + moderationStatus: 'active' as const, + termsAcceptedAt: new Date(), + termsVersion: input.termsVersion ?? CURRENT_TERMS_VERSION, }; mockDb.passwordUsers.push(user); @@ -317,6 +382,18 @@ async function loginMockEmailUser(input: EmailPasswordAuthInput) { throw unauthorized(ERROR_MESSAGES.INVALID_EMAIL_OR_PASSWORD); } + if (user.moderationStatus === 'suspended') { + throw unauthorized(ERROR_MESSAGES.ACCOUNT_SUSPENDED); + } + + if (!user.termsAcceptedAt || user.termsVersion !== CURRENT_TERMS_VERSION) { + if (!input.termsAccepted || input.termsVersion !== CURRENT_TERMS_VERSION) { + throw badRequest('최신 이용약관에 동의해야 로그인할 수 있습니다.'); + } + user.termsAcceptedAt = new Date(); + user.termsVersion = CURRENT_TERMS_VERSION; + } + setMockEmailUser(user); return createMockTokenPair(false); diff --git a/src/services/content-moderation.service.ts b/src/services/content-moderation.service.ts new file mode 100644 index 0000000..a5f6b53 --- /dev/null +++ b/src/services/content-moderation.service.ts @@ -0,0 +1,514 @@ +import { Prisma } from '@prisma/client'; + +import { env } from '../config/env.js'; +import { prisma } from '../config/prisma.js'; +import { ERROR_CODES, ERROR_MESSAGES } from '../constants/error.constants.js'; +import { CURRENT_TERMS_VERSION } from '../constants/legal.constants.js'; +import { HttpError, badRequest, notFound } from '../utils/http-error.js'; + +export const REPORT_RESPONSE_HOURS = 24; +export const REPORT_REMINDER_HOURS = 20; + +export const MODERATION_TARGET_TYPES = [ + 'user', + 'sound_pin', + 'recap', + 'travel_room_moment', + 'travel_room_comment', + 'mate_request', +] as const; + +export type ModerationTargetType = (typeof MODERATION_TARGET_TYPES)[number]; + +const blockedCompactTerms = [ + '씨발', + '시발', + '병신', + '개새끼', + '죽어', + '자살해', + 'nude', + 'porn', + 'kill yourself', +]; + +const contactPatterns = [ + /(?:https?:\/\/|www\.)\S+/iu, + /[\w.+-]+@[\w.-]+\.[a-z]{2,}/iu, + /(?:\+?82[- .]?)?0?1[016789][- .]?\d{3,4}[- .]?\d{4}/u, + /(?:카카오톡|카톡|텔레그램|telegram|line)\s*(?:아이디|id|:)?\s*[a-z0-9_.-]{3,}/iu, +]; + +function normalizeCompact(value: string) { + return value + .normalize('NFKC') + .toLocaleLowerCase('ko-KR') + .replace(/[\s\p{P}\p{S}_]+/gu, ''); +} + +export function inspectUserText(value: string) { + const trimmed = value.trim(); + const compact = normalizeCompact(trimmed); + const matchedTerm = blockedCompactTerms.find((term) => + compact.includes(normalizeCompact(term)), + ); + + if (matchedTerm) { + return { allowed: false as const, reason: 'objectionable_language' }; + } + + if (contactPatterns.some((pattern) => pattern.test(trimmed))) { + return { allowed: false as const, reason: 'external_contact' }; + } + + if (/(.)\1{7,}/u.test(compact)) { + return { allowed: false as const, reason: 'repeated_spam' }; + } + + return { allowed: true as const }; +} + +export function assertUserTextAllowed(fields: Record) { + for (const [field, value] of Object.entries(fields)) { + if (!value?.trim()) { + continue; + } + + const result = inspectUserText(value); + + if (!result.allowed) { + throw new HttpError(422, ERROR_CODES.CONTENT_REJECTED, ERROR_MESSAGES.CONTENT_REJECTED, { + field, + reason: result.reason, + }); + } + } +} + +async function resolveTarget(input: { + requestId?: string; + targetContentId?: string; + targetPinId?: string; + targetType: ModerationTargetType; + targetUserId?: string; +}) { + if (input.targetType === 'sound_pin') { + const pinId = input.targetContentId ?? input.targetPinId; + const pin = pinId ? await prisma.soundMapPin.findUnique({ where: { id: pinId } }) : null; + if (!pin) throw notFound('신고할 사운드 핀을 찾을 수 없습니다.'); + return { + targetUserId: pin.userId, + targetContentId: pin.id, + snapshot: { + id: pin.id, + placeName: pin.placeName, + track: pin.trackSnapshot, + visibility: pin.visibility, + }, + }; + } + + if (input.targetType === 'recap') { + const recap = input.targetContentId + ? await prisma.recap.findUnique({ where: { id: input.targetContentId } }) + : null; + if (!recap) throw notFound('신고할 리캡을 찾을 수 없습니다.'); + return { + targetUserId: recap.userId, + targetContentId: recap.id, + snapshot: { + id: recap.id, + title: recap.title, + placeName: recap.placeName, + backgroundImageUrl: recap.backgroundImageUrl, + visibility: recap.visibility, + }, + }; + } + + if (input.targetType === 'travel_room_moment') { + const moment = input.targetContentId + ? await prisma.travelRoomMoment.findUnique({ where: { id: input.targetContentId } }) + : null; + if (!moment) throw notFound('신고할 공동 리캡을 찾을 수 없습니다.'); + return { + targetUserId: moment.userId, + targetContentId: moment.id, + snapshot: { id: moment.id, note: moment.note, placeName: moment.placeName }, + }; + } + + if (input.targetType === 'travel_room_comment') { + const comment = input.targetContentId + ? await prisma.travelRoomMomentComment.findUnique({ where: { id: input.targetContentId } }) + : null; + if (!comment) throw notFound('신고할 댓글을 찾을 수 없습니다.'); + return { + targetUserId: comment.userId, + targetContentId: comment.id, + snapshot: { id: comment.id, body: comment.body, momentId: comment.momentId }, + }; + } + + if (input.targetType === 'mate_request') { + const requestId = input.targetContentId ?? input.requestId; + const request = requestId + ? await prisma.travelMateRequest.findUnique({ where: { id: requestId } }) + : null; + if (!request) throw notFound('신고할 동행 요청을 찾을 수 없습니다.'); + return { + targetUserId: request.requesterId, + targetContentId: request.id, + snapshot: { + id: request.id, + messageTemplate: request.messageTemplate, + requesterId: request.requesterId, + status: request.status, + }, + }; + } + + if (!input.targetUserId) { + throw badRequest('신고할 사용자가 필요합니다.'); + } + + const user = await prisma.user.findUnique({ + where: { id: input.targetUserId }, + select: { displayName: true, id: true }, + }); + if (!user) throw notFound('신고할 사용자를 찾을 수 없습니다.'); + + return { + targetUserId: user.id, + targetContentId: undefined, + snapshot: user, + }; +} + +async function notifyModeration(payload: Record) { + if (env.MODERATION_ALERT_MODE === 'cloud_logging') { + console.warn(JSON.stringify({ + severity: 'WARNING', + source: 'soundlog_moderation', + ...payload, + })); + return true; + } + + if (!env.MODERATION_ALERT_WEBHOOK_URL) { + return false; + } + + try { + const message = [ + '[Soundlog moderation]', + typeof payload.event === 'string' ? payload.event : 'new_event', + typeof payload.reportId === 'string' ? `report=${payload.reportId}` : undefined, + typeof payload.dueAt === 'string' ? `due=${payload.dueAt}` : undefined, + ] + .filter(Boolean) + .join(' '); + const response = await fetch(env.MODERATION_ALERT_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + content: message, + source: 'soundlog', + text: message, + ...payload, + }), + signal: AbortSignal.timeout(5000), + }); + return response.ok; + } catch (error) { + console.error('Failed to send moderation alert', error); + return false; + } +} + +async function hideTarget( + client: Prisma.TransactionClient, + targetType: string, + targetContentId?: string | null, +) { + if (!targetContentId) return; + + if (targetType === 'sound_pin') { + await client.soundMapPin.deleteMany({ where: { id: targetContentId } }); + } else if (targetType === 'recap') { + await client.recap.updateMany({ + where: { id: targetContentId }, + data: { moderationStatus: 'rejected', visibility: 'private' }, + }); + } else if (targetType === 'travel_room_moment') { + await client.travelRoomMoment.updateMany({ + where: { id: targetContentId }, + data: { moderationStatus: 'rejected' }, + }); + } else if (targetType === 'travel_room_comment') { + await client.travelRoomMomentComment.updateMany({ + where: { id: targetContentId }, + data: { moderationStatus: 'rejected' }, + }); + } else if (targetType === 'mate_request') { + await client.travelMateRequest.updateMany({ + where: { id: targetContentId }, + data: { status: 'cancelled' }, + }); + } +} + +export const contentModerationService = { + async listPendingContent(limit: number) { + const [recaps, moments] = await Promise.all([ + prisma.recap.findMany({ + where: { moderationStatus: 'pending', visibility: 'public' }, + orderBy: { createdAt: 'asc' }, + take: limit, + select: { + backgroundImageUrl: true, + createdAt: true, + id: true, + placeName: true, + title: true, + userId: true, + }, + }), + prisma.momentLog.findMany({ + where: { moderationStatus: 'pending', visibility: 'public' }, + orderBy: { createdAt: 'asc' }, + take: limit, + select: { + createdAt: true, + id: true, + note: true, + photoUrl: true, + placeName: true, + userId: true, + }, + }), + ]); + + return [ + ...recaps.map((recap) => ({ type: 'recap' as const, ...recap })), + ...moments.map((moment) => ({ type: 'moment_log' as const, ...moment })), + ] + .sort((first, second) => first.createdAt.getTime() - second.createdAt.getTime()) + .slice(0, limit); + }, + + async createReport(reporterId: string, input: { + details?: string; + reason: string; + requestId?: string; + targetContentId?: string; + targetPinId?: string; + targetType: ModerationTargetType; + targetUserId?: string; + }) { + assertUserTextAllowed({ details: input.details }); + const target = await resolveTarget(input); + + if (target.targetUserId === reporterId) { + throw badRequest('자신의 콘텐츠는 신고할 수 없습니다.'); + } + + const existing = await prisma.communityReport.findFirst({ + where: { + reason: input.reason, + reporterId, + status: 'pending', + targetContentId: target.targetContentId, + targetType: input.targetType, + targetUserId: target.targetUserId, + }, + orderBy: { createdAt: 'desc' }, + }); + if (existing) { + return { ...existing, notified: false }; + } + + const createdAt = new Date(); + const report = await prisma.communityReport.create({ + data: { + contentSnapshot: target.snapshot as Prisma.InputJsonValue, + createdAt, + details: input.details, + dueAt: new Date(createdAt.getTime() + REPORT_RESPONSE_HOURS * 60 * 60 * 1000), + reason: input.reason, + reporterId, + requestId: input.requestId, + targetContentId: target.targetContentId, + targetPinId: input.targetPinId, + targetType: input.targetType, + targetUserId: target.targetUserId, + }, + }); + + const notified = await notifyModeration({ + event: 'community_report_created', + reportId: report.id, + reason: report.reason, + targetType: report.targetType, + dueAt: report.dueAt.toISOString(), + }); + + return { ...report, notified }; + }, + + async createBlockReport(blockerId: string, input: { + targetContentId?: string; + targetPinId?: string; + targetType: ModerationTargetType; + targetUserId: string; + }) { + const target = await resolveTarget({ + targetContentId: input.targetContentId, + targetPinId: input.targetPinId, + targetType: input.targetType, + targetUserId: input.targetUserId, + }); + const createdAt = new Date(); + const report = await prisma.communityReport.create({ + data: { + contentSnapshot: { + action: 'user_blocked', + content: target.snapshot, + } as Prisma.InputJsonValue, + createdAt, + dueAt: new Date(createdAt.getTime() + REPORT_RESPONSE_HOURS * 60 * 60 * 1000), + reason: 'blocked_by_user', + reporterId: blockerId, + targetContentId: input.targetContentId, + targetPinId: input.targetPinId, + targetType: input.targetType, + targetUserId: input.targetUserId, + }, + }); + await notifyModeration({ + event: 'community_user_blocked', + reportId: report.id, + targetType: report.targetType, + dueAt: report.dueAt.toISOString(), + }); + return report; + }, + + async listReports(input: { limit: number; status?: string }) { + const now = new Date(); + const reports = await prisma.communityReport.findMany({ + where: input.status ? { status: input.status } : undefined, + orderBy: [{ dueAt: 'asc' }, { createdAt: 'asc' }], + take: input.limit, + }); + return reports.map((report) => ({ + ...report, + isOverdue: report.status === 'pending' && report.dueAt <= now, + })); + }, + + async resolveReport(reportId: string, input: { + action: 'dismiss' | 'hide_content' | 'hide_and_suspend'; + note: string; + resolvedBy: string; + }) { + return prisma.$transaction(async (transaction) => { + const report = await transaction.communityReport.findFirst({ + where: { id: reportId, status: 'pending' }, + }); + if (!report) throw notFound('처리 대기 중인 신고를 찾을 수 없습니다.'); + + if (input.action !== 'dismiss') { + await hideTarget( + transaction, + report.targetType, + report.targetContentId ?? report.targetPinId, + ); + } + + if (input.action === 'hide_and_suspend' && report.targetUserId) { + await transaction.user.updateMany({ + where: { id: report.targetUserId }, + data: { + moderationStatus: 'suspended', + suspendedAt: new Date(), + suspensionReason: input.note, + }, + }); + await transaction.refreshToken.deleteMany({ where: { userId: report.targetUserId } }); + await transaction.soundMapPin.deleteMany({ where: { userId: report.targetUserId } }); + } + + return transaction.communityReport.update({ + where: { id: report.id }, + data: { + resolution: `${input.action}: ${input.note}`, + resolvedAt: new Date(), + resolvedBy: input.resolvedBy, + status: input.action === 'dismiss' ? 'dismissed' : 'resolved', + }, + }); + }); + }, + + async sweepReportDeadlines() { + const now = new Date(); + const reminderThreshold = new Date( + now.getTime() - REPORT_REMINDER_HOURS * 60 * 60 * 1000, + ); + const [reminderReports, overdueReports] = await Promise.all([ + prisma.communityReport.findMany({ + where: { + status: 'pending', + reminderSentAt: null, + overdueSentAt: null, + createdAt: { lte: reminderThreshold }, + dueAt: { gt: now }, + }, + }), + prisma.communityReport.findMany({ + where: { + status: 'pending', + overdueSentAt: null, + dueAt: { lte: now }, + }, + }), + ]); + + for (const report of reminderReports) { + const notified = await notifyModeration({ + event: 'community_report_reminder', + reportId: report.id, + dueAt: report.dueAt.toISOString(), + }); + if (notified) { + await prisma.communityReport.update({ + where: { id: report.id }, + data: { reminderSentAt: now }, + }); + } + } + + for (const report of overdueReports) { + const notified = await notifyModeration({ + event: 'community_report_overdue', + reportId: report.id, + dueAt: report.dueAt.toISOString(), + }); + if (notified) { + await prisma.communityReport.update({ + where: { id: report.id }, + data: { overdueSentAt: now }, + }); + } + } + + return { + overdue: await prisma.communityReport.count({ + where: { status: 'pending', dueAt: { lte: now } }, + }), + overdueNotificationsAttempted: overdueReports.length, + remindersAttempted: reminderReports.length, + }; + }, +}; diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index bb25a9f..a407482 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -5,6 +5,7 @@ import { badRequest, forbidden, notFound } from '../utils/http-error.js'; import { getLimit, paginateByCursor } from '../utils/pagination.js'; import { findRegionalPlaylistId } from '../utils/regional-playlist.js'; import { createPublicId } from '../utils/tokens.js'; +import { assertUserTextAllowed, type ModerationTargetType } from './content-moderation.service.js'; type TrackDto = { albumImageUrl?: string; @@ -492,6 +493,12 @@ function refreshMockRecapAggregates(input: { moment.lat !== undefined && moment.lng !== undefined, ); + const hasPendingPublicPhoto = moments.some( + (moment) => + moment.visibility === 'public' && + moment.moderationStatus === 'pending' && + Boolean(moment.photoUrl), + ); return [{ ...recap, @@ -522,6 +529,12 @@ function refreshMockRecapAggregates(input: { templateId: recap.travelSessionId ? recap.templateId : representativeMoment.templateId, + moderationStatus: + recap.visibility === 'public' && hasPendingPublicPhoto + ? 'pending' as const + : recap.moderationStatus === 'pending' + ? 'approved' as const + : recap.moderationStatus, visibility: recap.visibility === 'public' && !hasPublicLocatedMoment ? 'private' as const @@ -565,6 +578,7 @@ function mockRecapMapMarkerToDto( : recap.title, placeName: publicRepresentative?.placeName ?? recap.placeName, ownerAlias: isMine ? '나' : 'Soundlog 여행자', + isMine, location, trackTitle: publicTrack?.title ?? track.title, artistName: publicTrack?.artist ?? track.artist, @@ -656,8 +670,12 @@ function hasMockCommunityBlockBetween(userId: string, targetUserId: string) { ); } -function roomToDto(room: (typeof mockDb.travelRooms)[number]) { - const members = mockDb.travelRoomMembers.filter((member) => member.roomId === room.id); +function roomToDto(room: (typeof mockDb.travelRooms)[number], viewerId?: string) { + const members = mockDb.travelRoomMembers.filter( + (member) => + member.roomId === room.id && + (!viewerId || member.userId === viewerId || !hasMockCommunityBlockBetween(viewerId, member.userId)), + ); const moments = mockDb.travelRoomMoments .filter((moment) => moment.roomId === room.id) .sort((first, second) => second.createdAt.getTime() - first.createdAt.getTime()); @@ -890,6 +908,102 @@ export const mockSoundlogService = { }; }, + async reviewModerationContent(input: { + contentId: string; + decision: 'approved' | 'rejected'; + reviewedBy?: string; + type: 'moment_log' | 'recap'; + }) { + if (input.type === 'moment_log') { + const moment = mockDb.momentLogs.find( + (item) => item.id === input.contentId && item.moderationStatus === 'pending', + ); + if (!moment) throw notFound('검토 대기 중인 리캡 촬영본을 찾을 수 없습니다.'); + moment.moderationStatus = input.decision; + if (input.decision === 'rejected') moment.visibility = 'private'; + refreshMockRecapAggregates({ + momentIds: [moment.id], + sessionIds: [moment.sessionId], + userId: moment.userId, + }); + } else { + const recap = mockDb.recaps.find( + (item) => item.id === input.contentId && item.moderationStatus === 'pending', + ); + if (!recap) throw notFound('검토 대기 중인 리캡을 찾을 수 없습니다.'); + const memberIds = new Set(getMockRecapMoments(recap).map((moment) => moment.id)); + const members = mockDb.momentLogs.filter((moment) => memberIds.has(moment.id)); + if (input.decision === 'approved' && members.some((moment) => moment.moderationStatus === 'pending')) { + throw badRequest('리캡에 포함된 사진을 각각 먼저 검토해야 합니다.'); + } + recap.moderationStatus = input.decision; + if (input.decision === 'rejected') { + recap.visibility = 'private'; + members.forEach((moment) => { + if (moment.moderationStatus === 'pending') { + moment.moderationStatus = 'rejected'; + moment.visibility = 'private'; + } + }); + } + } + + return { ...input, reviewedAt: new Date().toISOString() }; + }, + + async listPendingModerationContent(limit: number) { + return [ + ...mockDb.recaps + .filter((item) => item.moderationStatus === 'pending') + .map((item) => ({ ...item, type: 'recap' as const })), + ...mockDb.momentLogs + .filter((item) => item.moderationStatus === 'pending') + .map((item) => ({ ...item, type: 'moment_log' as const })), + ] + .sort((first, second) => first.createdAt.getTime() - second.createdAt.getTime()) + .slice(0, limit); + }, + + async listModerationReports(input: { limit: number; status?: string }) { + const now = new Date(); + return mockDb.communityReports + .filter((report) => !input.status || report.status === input.status) + .sort((first, second) => first.dueAt.getTime() - second.dueAt.getTime()) + .slice(0, input.limit) + .map((report) => ({ + ...report, + createdAt: report.createdAt.toISOString(), + dueAt: report.dueAt.toISOString(), + isOverdue: report.status === 'pending' && report.dueAt <= now, + })); + }, + + async resolveModerationReport(reportId: string, input: { + action: 'dismiss' | 'hide_content' | 'hide_and_suspend'; + note: string; + resolvedBy: string; + }) { + const report = mockDb.communityReports.find((item) => item.id === reportId); + if (!report) throw notFound('신고를 찾을 수 없습니다.'); + report.status = input.action === 'dismiss' ? 'dismissed' : 'resolved'; + if (input.action === 'hide_and_suspend' && report.targetUserId) { + const target = mockDb.passwordUsers.find((user) => user.id === report.targetUserId); + if (target) target.moderationStatus = 'suspended'; + } + return { ...report, resolution: `${input.action}: ${input.note}`, resolvedBy: input.resolvedBy }; + }, + + async sweepModerationDeadlines() { + const now = new Date(); + return { + overdue: mockDb.communityReports.filter( + (report) => report.status === 'pending' && report.dueAt <= now, + ).length, + overdueNotificationsAttempted: 0, + remindersAttempted: 0, + }; + }, + async getMyProfile() { return { ...mockDb.profile, @@ -1345,6 +1459,12 @@ export const mockSoundlogService = { travelMode?: string; visibility?: RecapVisibility; }, idempotencyKey?: string) { + assertUserTextAllowed({ + artistName: input.artistName, + note: input.note, + placeName: input.placeName, + trackTitle: input.trackTitle, + }); return withMockIdempotency( { idempotencyKey, scope: 'moment-log.create', userId }, () => { @@ -1383,6 +1503,9 @@ export const mockSoundlogService = { moodTags: input.moodTags, source: 'camera' as const, visibility: input.visibility ?? 'private', + moderationStatus: + input.visibility === 'public' && Boolean(input.photoPath) ? 'pending' as const : 'approved' as const, + userId, }; mockDb.momentLogs.unshift(log); @@ -1408,6 +1531,13 @@ export const mockSoundlogService = { throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); } + assertUserTextAllowed({ + artistName: input.artistName, + note: input.note ?? undefined, + placeName: input.placeName ?? undefined, + trackTitle: input.trackTitle, + }); + const previousSessionId = log.sessionId; if (hasOwn(input, 'lat') || hasOwn(input, 'lng') || input.visibility) { @@ -1505,6 +1635,7 @@ export const mockSoundlogService = { } log.photoUrl = `${env.UPLOAD_PUBLIC_BASE_URL}${photoPath}`; + log.moderationStatus = log.visibility === 'public' ? 'pending' : 'approved'; refreshMockRecapAggregates({ momentIds: [log.id], sessionIds: [log.sessionId], @@ -1522,6 +1653,7 @@ export const mockSoundlogService = { } log.photoUrl = undefined; + log.moderationStatus = 'approved'; refreshMockRecapAggregates({ momentIds: [log.id], sessionIds: [log.sessionId], @@ -1581,6 +1713,7 @@ export const mockSoundlogService = { title: string; visibility: string; }) { + assertUserTextAllowed({ title: input.title }); const now = new Date(); const room = { id: createPublicId('room'), @@ -1606,7 +1739,7 @@ export const mockSoundlogService = { visibility: room.visibility, }, { sessionId: room.sessionId ?? room.id }); - return roomToDto(room); + return roomToDto(room, userId); }, async getTravelRooms(userId: string, query: { @@ -1624,7 +1757,7 @@ export const mockSoundlogService = { .filter((room) => !query.sessionId || room.sessionId === query.sessionId) .sort((first, second) => second.updatedAt.getTime() - first.updatedAt.getTime()) .slice(0, getLimit(query.limit)) - .map(roomToDto); + .map((room) => roomToDto(room, userId)); }, async getTravelRoom(userId: string, roomId: string) { @@ -1637,13 +1770,14 @@ export const mockSoundlogService = { throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); } - return roomToDto(room); + return roomToDto(room, userId); }, async joinTravelRoom(userId: string, roomId: string, input: { displayName?: string; inviteCode?: string; }) { + assertUserTextAllowed({ displayName: input.displayName }); const room = mockDb.travelRooms.find((item) => item.id === roomId); if (!room) { @@ -1681,7 +1815,7 @@ export const mockSoundlogService = { roomId, }, { sessionId: room.sessionId ?? roomId }); - return roomToDto(room); + return roomToDto(room, userId); }, async joinTravelRoomByInviteCode(userId: string, input: { @@ -1706,6 +1840,12 @@ export const mockSoundlogService = { trackId?: string; trackTitle?: string; }) { + assertUserTextAllowed({ + artistName: input.artistName, + note: input.note, + placeName: input.placeName, + trackTitle: input.trackTitle, + }); const isMember = mockDb.travelRoomMembers.some( (member) => member.roomId === roomId && member.userId === userId, ); @@ -1816,6 +1956,7 @@ export const mockSoundlogService = { async addTravelRoomMomentComment(userId: string, roomId: string, momentId: string, input: { body: string; }) { + assertUserTextAllowed({ body: input.body }); const isMember = mockDb.travelRoomMembers.some( (member) => member.roomId === roomId && member.userId === userId, ); @@ -1863,6 +2004,7 @@ export const mockSoundlogService = { templateId?: string; title?: string; }, idempotencyKey?: string) { + assertUserTextAllowed({ title: input.title }); return withMockIdempotency( { idempotencyKey, scope: `travel-room-recap.create.${roomId}`, userId }, () => { @@ -1895,6 +2037,7 @@ export const mockSoundlogService = { recordedAt: recapMoments[0]?.createdAt ?? new Date(), templateId: input.templateId ?? 'album', visibility: 'private' as const, + moderationStatus: 'approved' as const, moments: recapMoments.map((moment) => ({ id: moment.id, placeName: moment.placeName ?? '위치 없음', @@ -1935,6 +2078,11 @@ export const mockSoundlogService = { ttlMinutes?: number; visibility: string; }) { + assertUserTextAllowed({ + artistName: input.artistName, + placeName: input.placeName, + trackTitle: input.trackTitle, + }); if (!input.sessionId) { throw badRequest(ERROR_MESSAGES.TRAVEL_SESSION_ACTIVE_REQUIRED); } @@ -2228,11 +2376,27 @@ export const mockSoundlogService = { return mateRequestToDto(request); }, - async blockCommunityUser(userId: string, input: { targetPinId?: string; targetUserId?: string }) { - const targetPin = input.targetPinId - ? mockDb.soundMapPins.find((pin) => pin.id === input.targetPinId) - : undefined; - const targetUserId = input.targetUserId ?? targetPin?.userId; + async blockCommunityUser(userId: string, input: { + targetContentId?: string; + targetPinId?: string; + targetType?: ModerationTargetType; + targetUserId?: string; + }) { + const targetType = input.targetType ?? (input.targetPinId ? 'sound_pin' : input.targetContentId ? 'recap' : 'user'); + const contentId = input.targetContentId ?? input.targetPinId; + const targetUserId = contentId + ? targetType === 'sound_pin' + ? mockDb.soundMapPins.find((pin) => pin.id === contentId)?.userId + : targetType === 'recap' + ? mockDb.recaps.find((recap) => recap.id === contentId)?.userId + : targetType === 'travel_room_moment' + ? mockDb.travelRoomMoments.find((moment) => moment.id === contentId)?.userId + : targetType === 'travel_room_comment' + ? mockDb.travelRoomMomentComments.find((comment) => comment.id === contentId)?.userId + : targetType === 'mate_request' + ? mockDb.travelMateRequests.find((request) => request.id === contentId)?.requesterId + : input.targetUserId + : input.targetUserId; if (!targetUserId) { throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); @@ -2272,25 +2436,36 @@ export const mockSoundlogService = { details?: string; reason: string; requestId?: string; + targetContentId?: string; targetPinId?: string; + targetType?: ModerationTargetType; targetUserId?: string; }) { - mockDb.communityReports.push({ + assertUserTextAllowed({ details: input.details }); + const createdAt = new Date(); + const dueAt = new Date(createdAt.getTime() + 24 * 60 * 60 * 1000); + const report = { id: createPublicId('report'), reporterId: userId, reason: input.reason, details: input.details, requestId: input.requestId, + status: 'pending', + targetContentId: input.targetContentId, targetPinId: input.targetPinId, + targetType: input.targetType ?? (input.targetPinId ? 'sound_pin' : 'user'), targetUserId: input.targetUserId, - createdAt: new Date(), - }); + createdAt, + dueAt, + }; + mockDb.communityReports.push(report); recordMockCommunityRecommendationEvent(userId, 'community_user_reported', { reason: input.reason, requestId: input.requestId, targetPinId: input.targetPinId, targetUserId: input.targetUserId, }, { sessionId: input.requestId ?? input.targetPinId ?? input.targetUserId ?? 'community' }); + return { id: report.id, dueAt: report.dueAt.toISOString(), notified: false }; }, async getRecaps(userId: string, params: { @@ -2301,6 +2476,8 @@ export const mockSoundlogService = { const scope = params.scope ?? 'mine'; const recaps = mockDb.recaps .filter((recap) => Boolean(recap.sessionId)) + .filter((recap) => recap.userId === userId || recap.moderationStatus === 'approved') + .filter((recap) => !hasMockCommunityBlockBetween(userId, recap.userId)) .filter((recap) => scope === 'mine' ? recap.userId === userId @@ -2343,6 +2520,8 @@ export const mockSoundlogService = { const radiusMeters = params.radiusMeters ?? RECAP_DISCOVERY_RADIUS_METERS; return mockDb.recaps + .filter((recap) => recap.userId === userId || recap.moderationStatus === 'approved') + .filter((recap) => !hasMockCommunityBlockBetween(userId, recap.userId)) .filter((recap) => scope === 'mine' ? recap.userId === userId @@ -2372,6 +2551,7 @@ export const mockSoundlogService = { title?: string; visibility?: RecapVisibility; }, idempotencyKey?: string) { + assertUserTextAllowed({ title: input.title }); return withMockIdempotency( { idempotencyKey, scope: 'recap.create', userId }, () => { @@ -2534,6 +2714,12 @@ export const mockSoundlogService = { templateId: input.templateId ?? 'album', thumbnailMomentId: thumbnailMoment.id, visibility: input.visibility ?? 'private', + moderationStatus: + input.visibility === 'public' && moments.some( + (moment) => moment.moderationStatus === 'pending' && Boolean(moment.photoUrl), + ) + ? 'pending' as const + : 'approved' as const, moments: moments.map((moment) => ({ id: moment.id, imageUrl: moment.photoUrl, @@ -2560,7 +2746,11 @@ export const mockSoundlogService = { async getRecapShare(userId: string, recapId: string) { const recap = mockDb.recaps.find( - (item) => item.id === recapId && (item.userId === userId || item.visibility === 'public'), + (item) => + item.id === recapId && + (item.userId === userId || + (item.visibility === 'public' && item.moderationStatus === 'approved')) && + !hasMockCommunityBlockBetween(userId, item.userId), ); if (!recap) { diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index 9a8e5ad..b08adcc 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -31,6 +31,11 @@ import { createPublicId } from '../utils/tokens.js'; import { badRequest, forbidden, notFound } from '../utils/http-error.js'; import { findRegionalPlaylistId } from '../utils/regional-playlist.js'; import { reverseGeocodeLocation } from './reverse-geocoding.service.js'; +import { + assertUserTextAllowed, + contentModerationService, + type ModerationTargetType, +} from './content-moderation.service.js'; type MaybeUser = { id: string } | undefined; @@ -310,23 +315,42 @@ function createTrackSnapshot(track?: Track | null, fallback?: { }; } -function roomToDto(room: RoomWithCommunity) { +function roomToDto(room: RoomWithCommunity, viewerId?: string, hiddenUserIds: string[] = []) { + const visibleMembers = room.members.filter( + (member) => member.userId === viewerId || !hiddenUserIds.includes(member.userId), + ); + const displayNameByUserId = new Map( + room.members.map((member) => [member.userId, member.displayName ?? undefined]), + ); + const visibleMoments = room.moments + .filter((moment) => + !hiddenUserIds.includes(moment.userId) && + (moment.moderationStatus === 'approved' || moment.userId === viewerId), + ) + .map((moment) => ({ + ...moment, + comments: moment.comments?.filter((comment) => + !hiddenUserIds.includes(comment.userId) && + (comment.moderationStatus === 'approved' || comment.userId === viewerId), + ), + })); + return { id: room.id, title: room.title, inviteCode: room.inviteCode, sessionId: room.sessionId ?? undefined, visibility: room.visibility, - memberCount: room.members.length, - momentCount: room.moments.length, - members: room.members.map((member) => ({ + memberCount: visibleMembers.length, + momentCount: visibleMoments.length, + members: visibleMembers.map((member) => ({ id: member.id, userId: member.userId, role: member.role, displayName: member.displayName ?? undefined, joinedAt: member.joinedAt.toISOString(), })), - moments: room.moments.map((moment) => ({ + moments: visibleMoments.map((moment) => ({ id: moment.id, userId: moment.userId, momentLogId: moment.momentLogId ?? undefined, @@ -338,6 +362,7 @@ function roomToDto(room: RoomWithCommunity) { comments: moment.comments?.map((comment) => ({ id: comment.id, userId: comment.userId, + displayName: displayNameByUserId.get(comment.userId), body: comment.body, createdAt: comment.createdAt.toISOString(), })) ?? [], @@ -499,6 +524,47 @@ async function hasCommunityBlockBetween(userId: string, targetUserId: string) { return Boolean(block); } +function inferModerationTargetType(input: { + requestId?: string; + targetContentId?: string; + targetPinId?: string; + targetType?: ModerationTargetType; +}) { + if (input.targetType) return input.targetType; + if (input.targetPinId) return 'sound_pin'; + if (input.requestId) return 'mate_request'; + return input.targetContentId ? 'recap' : 'user'; +} + +async function resolveCommunityTargetUserId(input: { + requestId?: string; + targetContentId?: string; + targetPinId?: string; + targetType?: ModerationTargetType; + targetUserId?: string; +}) { + const targetType = inferModerationTargetType(input); + const targetContentId = input.targetContentId ?? input.targetPinId ?? input.requestId; + if (!targetContentId) return input.targetUserId; + + if (targetType === 'sound_pin') { + return (await prisma.soundMapPin.findUnique({ where: { id: targetContentId } }))?.userId; + } + if (targetType === 'recap') { + return (await prisma.recap.findUnique({ where: { id: targetContentId } }))?.userId; + } + if (targetType === 'travel_room_moment') { + return (await prisma.travelRoomMoment.findUnique({ where: { id: targetContentId } }))?.userId; + } + if (targetType === 'travel_room_comment') { + return (await prisma.travelRoomMomentComment.findUnique({ where: { id: targetContentId } }))?.userId; + } + if (targetType === 'mate_request') { + return (await prisma.travelMateRequest.findUnique({ where: { id: targetContentId } }))?.requesterId; + } + return undefined; +} + function mateRequestToDto(request: TravelMateRequest) { return { id: request.id, @@ -1246,6 +1312,7 @@ function momentLogToDto(log: MomentLog) { placeId: log.placeId ?? undefined, placeName: log.placeName ?? undefined, note: log.note ?? undefined, + moderationStatus: log.moderationStatus, recapVisibility: log.visibility as RecapVisibility, templateId: log.templateId, track: (log.trackSnapshot as TrackDto | null) ?? undefined, @@ -1361,6 +1428,7 @@ function recapItemToDto( backgroundImageUrl: thumbnailMoment?.imageUrl ?? (isMine ? recap.backgroundImageUrl ?? undefined : undefined), thumbnailMomentId: thumbnailMoment?.id, visibility: recap.visibility, + moderationStatus: recap.moderationStatus, }); } @@ -1395,6 +1463,7 @@ function recapShareToDto( templateId: recap.templateId, thumbnailMomentId: thumbnailMoment?.id, visibility: recap.visibility, + moderationStatus: recap.moderationStatus, }); } @@ -1469,6 +1538,7 @@ function recapMapMarkerToDto( : recap.title, placeName: publicRepresentative?.placeName ?? recap.placeName, ownerAlias: isMine ? '나' : recap.user.displayName ?? 'Soundlog 여행자', + isMine, location, trackTitle: publicTrack?.title ?? recap.representativeTrack.title, artistName: publicTrack?.artist ?? recap.representativeTrack.artist, @@ -1601,6 +1671,12 @@ async function refreshRecapAggregates( moment.lat !== null && moment.lng !== null, ); + const hasPendingPublicPhoto = moments.some( + (moment) => + moment.visibility === 'public' && + moment.moderationStatus === 'pending' && + Boolean(moment.photoUrl), + ); await client.recap.update({ where: { id: recap.id }, @@ -1618,6 +1694,12 @@ async function refreshRecapAggregates( templateId: recap.travelSessionId ? recap.templateId : representativeMoment.templateId, + moderationStatus: + recap.visibility === 'public' && hasPendingPublicPhoto + ? 'pending' + : recap.moderationStatus === 'pending' + ? 'approved' + : recap.moderationStatus, visibility: recap.visibility === 'public' && !hasPublicLocatedMoment ? 'private' @@ -1725,6 +1807,102 @@ export const soundlogService = { }; }, + async reviewModerationContent(input: { + contentId: string; + decision: 'approved' | 'rejected'; + reviewedBy?: string; + type: 'moment_log' | 'recap'; + }) { + await prisma.$transaction(async (transaction) => { + if (input.type === 'recap') { + const recap = await transaction.recap.findFirst({ + where: { id: input.contentId, moderationStatus: 'pending' }, + }); + if (!recap) throw notFound('검토 대기 중인 리캡을 찾을 수 없습니다.'); + + const memberMomentIds = recapMomentsToDto(recap).map((moment) => moment.id); + if (input.decision === 'approved') { + const pendingMemberCount = await transaction.momentLog.count({ + where: { + id: { in: memberMomentIds }, + moderationStatus: 'pending', + userId: recap.userId, + }, + }); + if (pendingMemberCount > 0) { + throw badRequest('리캡에 포함된 사진을 각각 먼저 검토해야 합니다.'); + } + } else { + await transaction.momentLog.updateMany({ + where: { + id: { in: memberMomentIds }, + moderationStatus: 'pending', + userId: recap.userId, + }, + data: { moderationStatus: 'rejected', visibility: 'private' }, + }); + } + await transaction.recap.update({ + where: { id: recap.id }, + data: { + moderationStatus: input.decision, + moderationReviewedAt: new Date(), + moderationReviewedBy: input.reviewedBy ?? 'admin', + ...(input.decision === 'rejected' ? { visibility: 'private' } : {}), + }, + }); + await refreshRecapAggregates(transaction, { + momentIds: memberMomentIds, + sessionIds: [recap.sessionId], + userId: recap.userId, + }); + return; + } + + const moment = await transaction.momentLog.findFirst({ + where: { id: input.contentId, moderationStatus: 'pending' }, + }); + if (!moment) throw notFound('검토 대기 중인 리캡 촬영본을 찾을 수 없습니다.'); + + await transaction.momentLog.update({ + where: { id: moment.id }, + data: { + moderationStatus: input.decision, + moderationReviewedAt: new Date(), + moderationReviewedBy: input.reviewedBy ?? 'admin', + ...(input.decision === 'rejected' ? { visibility: 'private' } : {}), + }, + }); + await refreshRecapAggregates(transaction, { + momentIds: [moment.id], + sessionIds: [moment.sessionId], + userId: moment.userId, + }); + }); + + return { ...input, reviewedAt: new Date().toISOString() }; + }, + + async listPendingModerationContent(limit: number) { + return contentModerationService.listPendingContent(limit); + }, + + async listModerationReports(input: { limit: number; status?: string }) { + return contentModerationService.listReports(input); + }, + + async resolveModerationReport(reportId: string, input: { + action: 'dismiss' | 'hide_content' | 'hide_and_suspend'; + note: string; + resolvedBy: string; + }) { + return contentModerationService.resolveReport(reportId, input); + }, + + async sweepModerationDeadlines() { + return contentModerationService.sweepReportDeadlines(); + }, + async getMyProfile(userId: string) { const profile = await prisma.userProfile.upsert({ where: { userId }, @@ -2230,6 +2408,13 @@ export const soundlogService = { // to a saved row so the `finally` block can clean it up in every other case. let persisted = false; + assertUserTextAllowed({ + artistName: input.artistName, + note: input.note, + placeName: input.placeName, + trackTitle: input.trackTitle, + }); + try { return await withIdempotency( { idempotencyKey, scope: 'moment-log.create', userId }, @@ -2277,6 +2462,9 @@ export const soundlogService = { moodTags: input.moodTags, source: 'camera', visibility: input.visibility ?? 'private', + moderationStatus: input.visibility === 'public' && input.photoPath + ? 'pending' + : 'approved', }, }); @@ -2325,6 +2513,13 @@ export const soundlogService = { const data: Prisma.MomentLogUpdateInput = {}; + assertUserTextAllowed({ + artistName: input.artistName, + note: input.note ?? undefined, + placeName: input.placeName ?? undefined, + trackTitle: input.trackTitle, + }); + if (input.createdAt) { data.createdAt = new Date(input.createdAt); } @@ -2371,6 +2566,9 @@ export const soundlogService = { if (input.visibility) { data.visibility = input.visibility; + data.moderationStatus = input.visibility === 'public' && existing.photoUrl + ? 'pending' + : 'approved'; } if (hasOwn(input, 'lat') || hasOwn(input, 'lng') || input.visibility) { @@ -2449,7 +2647,10 @@ export const soundlogService = { const updated = await prisma.$transaction(async (transaction) => { const nextMoment = await transaction.momentLog.update({ where: { id: existing.id }, - data: { photoUrl: nextPhotoUrl }, + data: { + photoUrl: nextPhotoUrl, + moderationStatus: existing.visibility === 'public' ? 'pending' : 'approved', + }, }); await refreshRecapAggregates(transaction, { @@ -2494,7 +2695,7 @@ export const soundlogService = { const updated = await prisma.$transaction(async (transaction) => { const nextMoment = await transaction.momentLog.update({ where: { id: existing.id }, - data: { photoUrl: null }, + data: { moderationStatus: 'approved', photoUrl: null }, }); await refreshRecapAggregates(transaction, { @@ -2584,6 +2785,7 @@ export const soundlogService = { title: string; visibility: string; }) { + assertUserTextAllowed({ title: input.title }); const room = await prisma.travelRoom.create({ data: { id: createPublicId('room'), @@ -2613,7 +2815,7 @@ export const soundlogService = { visibility: room.visibility, }, { sessionId: room.sessionId ?? room.id }); - return roomToDto(room); + return roomToDto(room, userId); }, async getTravelRooms(userId: string, query: { @@ -2636,7 +2838,8 @@ export const soundlogService = { take: getLimit(query.limit), }); - return rooms.map(roomToDto); + const hiddenUserIds = await getCommunityHiddenUserIds(userId); + return rooms.map((room) => roomToDto(room, userId, hiddenUserIds)); }, async getTravelRoom(userId: string, roomId: string) { @@ -2658,13 +2861,15 @@ export const soundlogService = { throw notFound(ERROR_MESSAGES.TRAVEL_ROOM_NOT_FOUND); } - return roomToDto(room); + const hiddenUserIds = await getCommunityHiddenUserIds(userId); + return roomToDto(room, userId, hiddenUserIds); }, async joinTravelRoom(userId: string, roomId: string, input: { displayName?: string; inviteCode?: string; }) { + assertUserTextAllowed({ displayName: input.displayName }); const room = await prisma.travelRoom.findUnique({ where: { id: roomId }, include: { @@ -2741,6 +2946,12 @@ export const soundlogService = { trackId?: string; trackTitle?: string; }) { + assertUserTextAllowed({ + artistName: input.artistName, + note: input.note, + placeName: input.placeName, + trackTitle: input.trackTitle, + }); const member = await prisma.travelRoomMember.findUnique({ where: { roomId_userId: { @@ -2814,6 +3025,7 @@ export const soundlogService = { id: roomId, ownerId: userId, }, + include: { members: true }, }); if (!room) { @@ -2853,6 +3065,8 @@ export const soundlogService = { comments: updated.comments.map((comment) => ({ id: comment.id, userId: comment.userId, + displayName: room.members.find((member) => member.userId === comment.userId)?.displayName + ?? undefined, body: comment.body, createdAt: comment.createdAt.toISOString(), })), @@ -2863,6 +3077,7 @@ export const soundlogService = { async addTravelRoomMomentComment(userId: string, roomId: string, momentId: string, input: { body: string; }) { + assertUserTextAllowed({ body: input.body }); const member = await prisma.travelRoomMember.findUnique({ where: { roomId_userId: { @@ -2902,6 +3117,7 @@ export const soundlogService = { return { id: comment.id, userId: comment.userId, + displayName: member.displayName ?? undefined, body: comment.body, createdAt: comment.createdAt.toISOString(), }; @@ -2917,6 +3133,7 @@ export const soundlogService = { }, idempotencyKey?: string, ) { + assertUserTextAllowed({ title: input.title }); return withIdempotency( { idempotencyKey, scope: `travel-room-recap.create.${roomId}`, userId }, async () => { @@ -3003,6 +3220,11 @@ export const soundlogService = { ttlMinutes?: number; visibility: CommunityVisibility; }) { + assertUserTextAllowed({ + artistName: input.artistName, + placeName: input.placeName, + trackTitle: input.trackTitle, + }); if (!input.sessionId) { throw badRequest(ERROR_MESSAGES.TRAVEL_SESSION_ACTIVE_REQUIRED); } @@ -3392,11 +3614,13 @@ export const soundlogService = { return mateRequestToDto(updated); }, - async blockCommunityUser(userId: string, input: { targetPinId?: string; targetUserId?: string }) { - const targetPin = input.targetPinId - ? await prisma.soundMapPin.findUnique({ where: { id: input.targetPinId } }) - : undefined; - const targetUserId = input.targetUserId ?? targetPin?.userId; + async blockCommunityUser(userId: string, input: { + targetContentId?: string; + targetPinId?: string; + targetType?: ModerationTargetType; + targetUserId?: string; + }) { + const targetUserId = await resolveCommunityTargetUserId(input); if (!targetUserId) { throw badRequest(ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED); @@ -3435,32 +3659,38 @@ export const soundlogService = { targetPinId: input.targetPinId, targetUserId, }, { sessionId: input.targetPinId ?? targetUserId }); + + await contentModerationService.createBlockReport(userId, { + targetContentId: input.targetContentId, + targetPinId: input.targetPinId, + targetType: inferModerationTargetType(input), + targetUserId, + }); }, async reportCommunityTarget(userId: string, input: { details?: string; reason: string; requestId?: string; + targetContentId?: string; targetPinId?: string; + targetType?: ModerationTargetType; targetUserId?: string; }) { - await prisma.communityReport.create({ - data: { - details: input.details, - reason: input.reason, - reporterId: userId, - requestId: input.requestId, - targetPinId: input.targetPinId, - targetUserId: input.targetUserId, - }, + const targetType = inferModerationTargetType(input); + const report = await contentModerationService.createReport(userId, { + ...input, + targetType, }); await recordCommunityRecommendationEvent(userId, 'community_user_reported', { reason: input.reason, requestId: input.requestId, targetPinId: input.targetPinId, - targetUserId: input.targetUserId, + targetUserId: report.targetUserId, }, { sessionId: input.requestId ?? input.targetPinId ?? input.targetUserId ?? 'community' }); + + return { id: report.id, dueAt: report.dueAt.toISOString(), notified: report.notified }; }, async getRecaps(userId: string, params: { @@ -3469,15 +3699,24 @@ export const soundlogService = { scope?: RecapListScope; }) { const scope = params.scope ?? 'mine'; + const hiddenUserIds = await getCommunityHiddenUserIds(userId); const scopeWhere: Prisma.RecapWhereInput = scope === 'mine' ? { userId } : scope === 'others' - ? { userId: { not: userId }, visibility: 'public' } + ? { + moderationStatus: 'approved', + userId: { not: userId, notIn: hiddenUserIds }, + visibility: 'public', + } : { OR: [ { userId }, - { userId: { not: userId }, visibility: 'public' }, + { + moderationStatus: 'approved', + userId: { not: userId, notIn: hiddenUserIds }, + visibility: 'public', + }, ], }; const recaps = await prisma.recap.findMany({ @@ -3512,6 +3751,7 @@ export const soundlogService = { }, ) { const scope = params.scope ?? 'public'; + const hiddenUserIds = await getCommunityHiddenUserIds(userId); const origin = scope === 'public' && hasGeoPoint(params) ? { lat: params.lat!, lng: params.lng! } @@ -3520,7 +3760,13 @@ export const soundlogService = { const recaps = await prisma.recap.findMany({ where: scope === 'mine' ? { userId } - : { visibility: 'public' }, + : { + visibility: 'public', + OR: [ + { userId }, + { moderationStatus: 'approved', userId: { notIn: hiddenUserIds } }, + ], + }, include: { representativeTrack: true, user: { @@ -3562,6 +3808,7 @@ export const soundlogService = { }, idempotencyKey?: string, ) { + assertUserTextAllowed({ title: input.title }); return withIdempotency( { idempotencyKey, scope: 'recap.create', userId }, async () => { @@ -3747,6 +3994,9 @@ export const soundlogService = { templateId: input.templateId, thumbnailMomentId: thumbnailMoment.id, visibility: input.visibility ?? 'private', + moderationStatus: input.visibility === 'public' && Boolean(thumbnailMoment.photoUrl) + ? 'pending' + : 'approved', lat: recapLocation?.lat, lng: recapLocation?.lng, }, @@ -3777,12 +4027,17 @@ export const soundlogService = { }, async getRecapShare(userId: string, recapId: string) { + const hiddenUserIds = await getCommunityHiddenUserIds(userId); const recap = await prisma.recap.findFirst({ where: { id: recapId, OR: [ { userId }, - { visibility: 'public' }, + { + moderationStatus: 'approved', + userId: { notIn: hiddenUserIds }, + visibility: 'public', + }, ], }, include: { representativeTrack: true, travelSession: true }, @@ -3860,7 +4115,13 @@ export const soundlogService = { await transaction.recap.update({ where: { id: recapId }, - data: { visibility: input.visibility }, + data: { + visibility: input.visibility, + moderationStatus: + input.visibility === 'public' && Boolean(recap.backgroundImageUrl) + ? 'pending' + : 'approved', + }, }); await refreshRecapAggregates(transaction, { diff --git a/src/services/upload-file.service.ts b/src/services/upload-file.service.ts index 3d4aeb5..3df154b 100644 --- a/src/services/upload-file.service.ts +++ b/src/services/upload-file.service.ts @@ -120,7 +120,7 @@ async function resolveUploadedFileForUser( // which filename exists and who owns it. const momentLog = await prisma.momentLog.findFirst({ where: { photoUrl: { endsWith: `/${fileId}` } }, - select: { userId: true, visibility: true }, + select: { moderationStatus: true, userId: true, visibility: true }, }); if (!momentLog) { @@ -128,12 +128,28 @@ async function resolveUploadedFileForUser( } const isOwner = momentLog.userId === userId; - const isPublic = momentLog.visibility === 'public'; + const isPublic = + momentLog.visibility === 'public' && momentLog.moderationStatus === 'approved'; if (!isOwner && !isPublic) { return undefined; } + if (!isOwner) { + const block = await prisma.communityBlock.findFirst({ + where: { + OR: [ + { blockerId: userId, blockedUserId: momentLog.userId }, + { blockerId: momentLog.userId, blockedUserId: userId }, + ], + }, + select: { id: true }, + }); + if (block) { + return undefined; + } + } + // Build the path from the server-known upload root and the validated fileId only, then // re-verify (defense in depth) that the resolved path is still inside the upload root. const absolutePath = path.resolve(uploadRoot, fileId); @@ -155,7 +171,36 @@ async function resolveUploadedFileForUser( return { absolutePath }; } +async function resolveUploadedFileForModeration( + fileId: string, +): Promise { + if (!UPLOAD_FILE_ID_PATTERN.test(fileId)) { + return undefined; + } + + const referenced = await prisma.momentLog.findFirst({ + where: { photoUrl: { endsWith: `/${fileId}` } }, + select: { id: true }, + }); + if (!referenced) { + return undefined; + } + + const absolutePath = path.resolve(uploadRoot, fileId); + if (absolutePath !== path.join(uploadRoot, fileId) || !absolutePath.startsWith(`${uploadRoot}${path.sep}`)) { + return undefined; + } + + try { + const stat = await fs.stat(absolutePath); + return stat.isFile() ? { absolutePath } : undefined; + } catch { + return undefined; + } +} + export const uploadFileService = { detectImageContentType, + resolveUploadedFileForModeration, resolveUploadedFileForUser, }; diff --git a/src/validators/api.validators.ts b/src/validators/api.validators.ts index ea12bcd..3ebcab3 100644 --- a/src/validators/api.validators.ts +++ b/src/validators/api.validators.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { CURRENT_TERMS_VERSION } from '../constants/legal.constants.js'; + import { ERROR_MESSAGES } from '../constants/error.constants.js'; const optionalCsvArray = z.preprocess((value) => { @@ -113,11 +115,15 @@ export const authValidators = { loginBody: z.object({ email: z.string().trim().email(), password: z.string().min(8).max(128), + termsAccepted: z.literal(true).optional(), + termsVersion: z.literal(CURRENT_TERMS_VERSION).optional(), }), registerBody: z.object({ displayName: z.string().trim().min(1).max(120).optional(), email: z.string().trim().email(), password: z.string().min(8).max(128), + termsAccepted: z.literal(true), + termsVersion: z.literal(CURRENT_TERMS_VERSION), }), logoutBody: z .object({ @@ -492,18 +498,61 @@ export const communityValidators = { }), blockBody: z .object({ + targetContentId: z.string().min(1).optional(), targetPinId: z.string().optional(), + targetType: z + .enum(['user', 'sound_pin', 'recap', 'travel_room_moment', 'travel_room_comment', 'mate_request']) + .optional(), targetUserId: z.string().min(1).optional(), }) - .refine((value) => Boolean(value.targetUserId || value.targetPinId), { + .refine( + (value) => Boolean(value.targetUserId || value.targetPinId || value.targetContentId), + { message: ERROR_MESSAGES.TRAVEL_MATE_TARGET_REQUIRED, - }), + }, + ), reportBody: z.object({ details: z.string().trim().max(500).optional(), reason: z.enum(['safety', 'spam', 'inappropriate', 'other']), requestId: z.string().optional(), + targetContentId: z.string().min(1).optional(), targetPinId: z.string().optional(), + targetType: z + .enum(['user', 'sound_pin', 'recap', 'travel_room_moment', 'travel_room_comment', 'mate_request']) + .optional(), targetUserId: z.string().optional(), + }).refine( + (value) => Boolean( + value.targetUserId || value.targetPinId || value.targetContentId || value.requestId, + ), + { message: '신고 대상이 필요합니다.' }, + ), +}; + +export const moderationValidators = { + contentListQuery: z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + }), + contentParams: z.object({ + contentId: z.string().min(1), + }), + contentImageParams: z.object({ + fileId: z.string().regex(/^[a-f0-9]{32}$/i), + }), + contentReviewBody: z.object({ + decision: z.enum(['approved', 'rejected']), + type: z.enum(['moment_log', 'recap']), + }), + listQuery: z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + status: z.enum(['pending', 'resolved', 'dismissed']).optional(), + }), + reportParams: z.object({ + reportId: z.string().min(1), + }), + resolveBody: z.object({ + action: z.enum(['dismiss', 'hide_content', 'hide_and_suspend']), + note: z.string().trim().min(1).max(500), }), }; diff --git a/tests/api.test.ts b/tests/api.test.ts index e1d1a59..88dde36 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -9,6 +9,8 @@ import { env } from '../src/config/env.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 { apiService } from '../src/services/api.service.js'; +import { mockSoundlogService } from '../src/services/mock-soundlog.service.js'; import { disconnectSeedDatabase, seedDatabase } from '../prisma/seed.js'; function fileIdFromPhotoUrl(photoUrl: string) { @@ -28,12 +30,17 @@ function fakeJpegBuffer(label: string) { const app = createApp(); const useMockDb = process.env.USE_MOCK_DB === 'true'; +const currentTermsAcceptance = { + termsAccepted: true, + termsVersion: '2026-08-15', +}; async function getToken() { const email = `local-${Date.now()}@soundlog.test`; const password = 'soundlog-password'; const register = await request(app).post('/v1/auth/register').send({ + ...currentTermsAcceptance, displayName: 'Local Soundlog User', email, password, @@ -261,6 +268,7 @@ describe('Soundlog API', () => { it('refreshes auth tokens', async () => { const password = 'refresh-password'; const register = await request(app).post('/v1/auth/register').send({ + ...currentTermsAcceptance, displayName: 'Refresh User', email: `refresh-${Date.now()}@soundlog.test`, password, @@ -275,11 +283,94 @@ describe('Soundlog API', () => { expect(response.body.data.user.id).toEqual(expect.any(String)); }); + it('requires the current terms and stores acceptance evidence', async () => { + const email = `terms-${Date.now()}@soundlog.test`; + const rejected = await request(app).post('/v1/auth/register').send({ + email, + password: 'soundlog-password', + }); + expect(rejected.status).toBe(400); + + const accepted = await request(app).post('/v1/auth/register').send({ + ...currentTermsAcceptance, + displayName: 'Terms User', + email, + password: 'soundlog-password', + }); + expect(accepted.status).toBe(201); + expect(accepted.body.data.user.termsVersion).toBe('2026-08-15'); + expect(accepted.body.data.user.termsAcceptedAt).toEqual(expect.any(String)); + }); + + it('filters unsafe text and operates the 24-hour moderation queue', async () => { + const unsafeRoom = await request(app) + .post('/v1/travel-rooms') + .set('Authorization', authHeader) + .send({ title: '시 발 우회 제목', visibility: 'invite_only' }); + expect(unsafeRoom.status).toBe(422); + expect(unsafeRoom.body.error.code).toBe('CONTENT_REJECTED'); + + const targetEmail = `moderation-target-${Date.now()}@soundlog.test`; + const targetPassword = 'soundlog-password'; + const target = await request(app).post('/v1/auth/register').send({ + ...currentTermsAcceptance, + displayName: 'Moderation Target', + email: targetEmail, + password: targetPassword, + }); + expect(target.status).toBe(201); + + const report = await request(app) + .post('/v1/community/reports') + .set('Authorization', authHeader) + .send({ + reason: 'safety', + targetType: 'user', + targetUserId: target.body.data.user.id, + }); + expect(report.status).toBe(202); + expect(new Date(report.body.data.dueAt).getTime()).toBeGreaterThan(Date.now()); + + const previousAdminKey = env.MODERATION_ADMIN_KEY; + env.MODERATION_ADMIN_KEY = 'test-moderation-admin-key'; + try { + const unauthorizedList = await request(app) + .get('/v1/admin/moderation/reports') + .set('x-soundlog-admin-key', 'wrong-key'); + expect(unauthorizedList.status).toBe(401); + + const list = await request(app) + .get('/v1/admin/moderation/reports') + .query({ status: 'pending' }) + .set('x-soundlog-admin-key', env.MODERATION_ADMIN_KEY); + expect(list.status).toBe(200); + expect(list.body.data.some((item: { id: string }) => item.id === report.body.data.id)).toBe(true); + + const resolved = await request(app) + .patch(`/v1/admin/moderation/reports/${report.body.data.id}`) + .set('x-soundlog-admin-key', env.MODERATION_ADMIN_KEY) + .set('x-soundlog-admin-actor', 'api-test') + .send({ action: 'hide_and_suspend', note: 'API moderation test' }); + expect(resolved.status).toBe(200); + expect(resolved.body.data.status).toBe('resolved'); + + const suspendedLogin = await request(app).post('/v1/auth/login').send({ + email: targetEmail, + password: targetPassword, + }); + expect(suspendedLogin.status).toBe(401); + expect(suspendedLogin.body.error.message).toContain('이용이 제한된 계정'); + } finally { + env.MODERATION_ADMIN_KEY = previousAdminKey; + } + }); + it('stores first-party passwords only as one-way bcrypt hashes', async () => { const email = `secure-${Date.now()}@soundlog.test`; const password = 'soundlog-password-SECURE-123!'; const register = await request(app).post('/v1/auth/register').send({ + ...currentTermsAcceptance, displayName: 'Password Security User', email, password, @@ -327,6 +418,7 @@ describe('Soundlog API', () => { expect(removedMigration.status).toBe(404); const login = await request(app).post('/v1/auth/register').send({ + ...currentTermsAcceptance, email: `logout-${Date.now()}@soundlog.test`, password: 'logout-password', }); @@ -341,6 +433,7 @@ describe('Soundlog API', () => { const email = `delete-${Date.now()}@soundlog.test`; const password = 'delete-account-password'; const register = await request(app).post('/v1/auth/register').send({ + ...currentTermsAcceptance, displayName: 'Delete Account User', email, password, @@ -1223,6 +1316,7 @@ describe('Soundlog API', () => { const otherEmail = `public-log-${Date.now()}@soundlog.test`; const otherRegister = await request(app).post('/v1/auth/register').send({ + ...currentTermsAcceptance, displayName: 'Public Log Traveler', email: otherEmail, password: 'soundlog-password', @@ -1256,7 +1350,7 @@ describe('Soundlog API', () => { trackId: 'moon-seoul', visibility: 'private', }); - await createTestMomentLog({ + const otherLatestPublicMoment = await createTestMomentLog({ authHeader: otherAuthHeader, filename: 'other-latest-public-recap.jpg', lat: 37.5515, @@ -1288,6 +1382,96 @@ describe('Soundlog API', () => { mineList.body.data.some((recap: { id: string }) => recap.id === otherPublicCreated.body.data.id), ).toBe(false); + const pendingOthersList = await request(app) + .get('/v1/recaps') + .query({ scope: 'others' }) + .set('Authorization', authHeader); + expect( + pendingOthersList.body.data.some( + (recap: { id: string }) => recap.id === otherPublicCreated.body.data.id, + ), + ).toBe(false); + + if (!useMockDb) { + const previousAdminKey = env.MODERATION_ADMIN_KEY; + env.MODERATION_ADMIN_KEY = 'test-content-review-admin-key'; + try { + const publicTextMoment = await request(app) + .post('/v1/moment-logs') + .set('Authorization', otherAuthHeader) + .field('createdAt', new Date().toISOString()) + .field('lat', '37.552') + .field('lng', '126.989') + .field('moodTags', 'fresh,calm') + .field('placeName', '사진 교체 검토 장소') + .field('trackId', 'seoul-city') + .field('visibility', 'public'); + expect(publicTextMoment.status).toBe(201); + expect(publicTextMoment.body.data.moderationStatus).toBe('approved'); + + const publicPhotoUpdated = await request(app) + .put(`/v1/moment-logs/${publicTextMoment.body.data.id}/photo`) + .set('Authorization', otherAuthHeader) + .attach('photo', fakeJpegBuffer('public-photo-review'), { + filename: 'public-photo-review.jpg', + contentType: 'image/jpeg', + }); + expect(publicPhotoUpdated.status).toBe(200); + expect(publicPhotoUpdated.body.data.moderationStatus).toBe('pending'); + + const publicPhotoDeleted = await request(app) + .delete(`/v1/moment-logs/${publicTextMoment.body.data.id}/photo`) + .set('Authorization', otherAuthHeader); + expect(publicPhotoDeleted.status).toBe(200); + expect(publicPhotoDeleted.body.data.moderationStatus).toBe('approved'); + + const pendingContent = await request(app) + .get('/v1/admin/moderation/content') + .query({ limit: 100 }) + .set('x-soundlog-admin-key', env.MODERATION_ADMIN_KEY); + expect(pendingContent.status).toBe(200); + expect(pendingContent.body.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: otherPublicCreated.body.data.id, type: 'recap' }), + ]), + ); + + const approved = await request(app) + .patch(`/v1/admin/moderation/content/${otherFirstPublicMoment.id}`) + .set('x-soundlog-admin-key', env.MODERATION_ADMIN_KEY) + .send({ decision: 'approved', type: 'moment_log' }); + expect(approved.status).toBe(200); + const latestApproved = await request(app) + .patch(`/v1/admin/moderation/content/${otherLatestPublicMoment.id}`) + .set('x-soundlog-admin-key', env.MODERATION_ADMIN_KEY) + .send({ decision: 'approved', type: 'moment_log' }); + expect(latestApproved.status).toBe(200); + + const approvedMemberMoments = await prisma.momentLog.findMany({ + where: { + id: { in: [otherFirstPublicMoment.id] }, + }, + select: { moderationStatus: true }, + }); + expect(approvedMemberMoments).toEqual([ + expect.objectContaining({ moderationStatus: 'approved' }), + ]); + } finally { + env.MODERATION_ADMIN_KEY = previousAdminKey; + } + } else { + await mockSoundlogService.reviewModerationContent({ + contentId: otherFirstPublicMoment.id, + decision: 'approved', + type: 'moment_log', + }); + await mockSoundlogService.reviewModerationContent({ + contentId: otherLatestPublicMoment.id, + decision: 'approved', + type: 'moment_log', + }); + } + const othersList = await request(app) .get('/v1/recaps') .query({ scope: 'others' }) @@ -1403,6 +1587,17 @@ describe('Soundlog API', () => { .send({ momentId: secondRecapMoment.id }); expect(forbiddenThumbnailUpdate.status).toBe(404); + await apiService.reviewModerationContent({ + contentId: firstRecapMoment.id, + decision: 'approved', + type: 'moment_log', + }); + await apiService.reviewModerationContent({ + contentId: secondRecapMoment.id, + decision: 'approved', + type: 'moment_log', + }); + const publicShareAsOther = await request(app) .get(`/v1/recaps/${createdRecapId}/share`) .set('Authorization', otherAuthHeader); @@ -1554,6 +1749,7 @@ describe('Soundlog API', () => { const targetEmail = `target-${Date.now()}@soundlog.test`; const targetPassword = 'soundlog-password'; const targetRegister = await request(app).post('/v1/auth/register').send({ + ...currentTermsAcceptance, displayName: 'Nearby Sound Traveler', email: targetEmail, password: targetPassword, @@ -1570,6 +1766,7 @@ describe('Soundlog API', () => { const outsiderEmail = `outsider-${Date.now()}@soundlog.test`; const outsiderPassword = 'soundlog-password'; const outsiderRegister = await request(app).post('/v1/auth/register').send({ + ...currentTermsAcceptance, displayName: 'Unrelated Traveler', email: outsiderEmail, password: outsiderPassword, @@ -1911,7 +2108,10 @@ describe('Soundlog API', () => { const block = await request(app) .post('/v1/community/blocks') .set('Authorization', authHeader) - .send({ targetPinId: targetPin.body.data.id }); + .send({ + targetContentId: sharedMoment.body.data.id, + targetType: 'travel_room_moment', + }); expect(block.status).toBe(202); const blockedMateRequest = await request(app) @@ -1988,7 +2188,7 @@ describe('Soundlog API', () => { expect([401, 404]).toContain(response.status); }); - it('lets another authenticated user fetch a public moment photo', async () => { + it('keeps a public photo private until moderation approves it', async () => { const moment = await createTestMomentLog({ authHeader, filename: 'owner-public.jpg', @@ -2004,7 +2204,31 @@ describe('Soundlog API', () => { .get(`/v1/uploads/${fileId}`) .set('Authorization', `Bearer ${otherAccessToken}`); - expect(response.status).toBe(200); + expect(response.status).toBe(404); + + const previousAdminKey = env.MODERATION_ADMIN_KEY; + env.MODERATION_ADMIN_KEY = 'test-content-image-admin-key'; + try { + const moderationImage = await request(app) + .get(`/v1/admin/moderation/content-images/${fileId}`) + .set('x-soundlog-admin-key', env.MODERATION_ADMIN_KEY); + expect(moderationImage.status).toBe(200); + expect(moderationImage.headers['content-type']).toMatch(/^image\/jpeg/); + + const approved = await request(app) + .patch(`/v1/admin/moderation/content/${moment.id}`) + .set('x-soundlog-admin-key', env.MODERATION_ADMIN_KEY) + .send({ decision: 'approved', type: 'moment_log' }); + expect(approved.status).toBe(200); + } finally { + env.MODERATION_ADMIN_KEY = previousAdminKey; + } + + const approvedResponse = await request(app) + .get(`/v1/uploads/${fileId}`) + .set('Authorization', `Bearer ${otherAccessToken}`); + + expect(approvedResponse.status).toBe(200); }); it('returns 404 for a well-formed file id that does not exist', async () => { diff --git a/tests/content-moderation.test.ts b/tests/content-moderation.test.ts new file mode 100644 index 0000000..c8ebb2c --- /dev/null +++ b/tests/content-moderation.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertUserTextAllowed, + inspectUserText, +} from '../src/services/content-moderation.service.js'; + +describe('content moderation text filter', () => { + it('allows ordinary Korean travel text', () => { + expect(inspectUserText('바다 산책하며 듣기 좋은 곡이에요')).toEqual({ allowed: true }); + }); + + it.each([ + '시 발 이라고 띄어 쓴 우회 표현', + 'KILL YOURSELF', + '텔레그램 ID abc_123', + '연락처는 010-1234-5678', + '도배도배도배ㅋㅋㅋㅋㅋㅋㅋㅋ', + ])('rejects objectionable or unsafe input: %s', (value) => { + expect(inspectUserText(value).allowed).toBe(false); + }); + + it('returns a stable client-facing error code and field', () => { + expect(() => assertUserTextAllowed({ comment: '시.발' })).toThrowError( + expect.objectContaining({ + code: 'CONTENT_REJECTED', + details: expect.objectContaining({ field: 'comment' }), + statusCode: 422, + }), + ); + }); +}); diff --git a/tests/legal-pages.test.ts b/tests/legal-pages.test.ts index 90f9174..4cbaeaf 100644 --- a/tests/legal-pages.test.ts +++ b/tests/legal-pages.test.ts @@ -50,4 +50,13 @@ describe('public legal pages', () => { expect(support.text).toContain('별도의 웹 서비스나 웹 계정 기능을 제공하지 않습니다'); expect(terms.text).toContain('별도의 웹 서비스를 제공하지 않습니다'); }); + + it('publishes the App Review user-content safeguards in the terms', async () => { + const response = await request(app).get('/legal/terms'); + + expect(response.status).toBe(200); + expect(response.text).toContain('불쾌하거나 폭력적이거나 혐오적이거나 성적인 콘텐츠를 용납하지 않습니다'); + expect(response.text).toContain('신고 접수 후 24시간 안에 검토'); + expect(response.text).toContain('차단한 사용자의 콘텐츠는 즉시 피드와 지도에서 숨겨집니다'); + }); }); diff --git a/tests/mock-soundlog.service.test.ts b/tests/mock-soundlog.service.test.ts index 76e2fe0..ec6b254 100644 --- a/tests/mock-soundlog.service.test.ts +++ b/tests/mock-soundlog.service.test.ts @@ -331,6 +331,13 @@ describe('mockSoundlogService', () => { title: '다른 사람 공개 로그', visibility: 'public', }); + const pendingOthersList = await mockSoundlogService.getRecaps(ownerId, { scope: 'others' }); + expect(pendingOthersList.data.map((item) => item.id)).not.toContain(otherPublicRecap.id); + await mockSoundlogService.reviewModerationContent({ + contentId: requireValue(otherMoment.id), + decision: 'approved', + type: 'moment_log', + }); const mineList = await mockSoundlogService.getRecaps(ownerId, { scope: 'mine' }); const othersList = await mockSoundlogService.getRecaps(ownerId, { scope: 'others' }); const allList = await mockSoundlogService.getRecaps(ownerId, { scope: 'all' });