diff --git a/apps/mobile/src/app/profile/blocks.tsx b/apps/mobile/src/app/profile/blocks.tsx
new file mode 100644
index 00000000..d4eb17bc
--- /dev/null
+++ b/apps/mobile/src/app/profile/blocks.tsx
@@ -0,0 +1,6 @@
+import { BlockedUsersScreen } from "../../features/user-block/ui/blocked-users-screen";
+
+/** 차단한 사용자 라우트 (MSG-570 기준 12) — 프로필·설정에서 push */
+export default function ProfileBlocks() {
+ return ;
+}
diff --git a/apps/mobile/src/features/auth/model/app-entry.ts b/apps/mobile/src/features/auth/model/app-entry.ts
index a1a5dab1..883261e1 100644
--- a/apps/mobile/src/features/auth/model/app-entry.ts
+++ b/apps/mobile/src/features/auth/model/app-entry.ts
@@ -18,6 +18,7 @@ export const PROTECTED_ROUTES = [
"dex/history",
"grid/[cellId]",
"profile",
+ "profile/blocks",
"profile/consent",
"profile/edit",
"profile/reports",
diff --git a/apps/mobile/src/features/event/api/event-video-mutations.ts b/apps/mobile/src/features/event/api/event-video-mutations.ts
index 38c64f2d..ce7107f8 100644
--- a/apps/mobile/src/features/event/api/event-video-mutations.ts
+++ b/apps/mobile/src/features/event/api/event-video-mutations.ts
@@ -37,8 +37,9 @@ type DetailEnvelope = ApiResponseDtoEventVideoDetailResponseDto;
/**
* 상세 캐시 seed — 봉투를 유지한 채 data만 갱신하고 갱신된 상세를 돌려준다.
* 캐시 부재(시트 닫힘 후 gc)면 no-op·undefined.
+ * MSG-570 댓글 작성자 차단(`use-event-video-sheet`)도 이 경로로 상세를 갱신한다 — 상세 invalidate 금지.
*/
-const seedDetail = (
+export const seedDetail = (
queryClient: QueryClient,
videoId: number,
update: (detail: EventVideoDetailResponseDto) => EventVideoDetailResponseDto,
diff --git a/apps/mobile/src/features/event/api/use-event-comments-pages.ts b/apps/mobile/src/features/event/api/use-event-comments-pages.ts
index 6f939839..5d7c43c1 100644
--- a/apps/mobile/src/features/event/api/use-event-comments-pages.ts
+++ b/apps/mobile/src/features/event/api/use-event-comments-pages.ts
@@ -36,10 +36,14 @@ export interface EventCommentsPagesResult {
/** 다음 페이지 이어받기 — 진행 중이거나 더 없으면 아무것도 하지 않는다 */
loadMore: () => void;
isLoadingMore: boolean;
+ /** 이어받은 페이지를 비운다 (MSG-570 기준 11) — 댓글 작성자 차단 후 첫 페이지 seed와 함께 */
+ reset: () => void;
}
interface ExtraPagesState {
videoId: number;
+ /** `reset()` 세대 — 리셋 전에 띄운 "더 보기" 응답이 리셋 뒤에 붙지 않게 (codex 리뷰 P2) */
+ generation: number;
pages: EventVideoCommentPageResponseDto[];
}
@@ -55,12 +59,16 @@ export const useEventCommentsPages = (
onLoadError?: (error: unknown) => void,
): EventCommentsPagesResult => {
const queryClient = useQueryClient();
- const [extra, setExtra] = useState({ videoId, pages: [] });
+ const [extra, setExtra] = useState({
+ videoId,
+ generation: 0,
+ pages: [],
+ });
const [isLoadingMore, setIsLoadingMore] = useState(false);
// 영상 교체 시 축적 리셋 — 렌더 중 상태 조정 (React 공식 "adjusting state" 패턴)
if (extra.videoId !== videoId) {
- setExtra({ videoId, pages: [] });
+ setExtra({ videoId, generation: 0, pages: [] });
}
const pages = extra.videoId === videoId ? extra.pages : [];
@@ -69,13 +77,14 @@ export const useEventCommentsPages = (
const loadMore = () => {
if (cursor === null || isLoadingMore) return;
setIsLoadingMore(true);
+ const generation = extra.generation;
void (async () => {
try {
const page = await fetchCommentsPage(queryClient, videoId, cursor);
- // 요청 중 영상이 교체됐으면 폐기 — 새 영상 목록에 이전 페이지가 섞이지 않게
+ // 요청 중 영상이 교체됐거나 리셋(차단)됐으면 폐기 — 이전 페이지가 새 목록에 섞이지 않게
setExtra((prev) =>
- prev.videoId === videoId
- ? { videoId, pages: [...prev.pages, page] }
+ prev.videoId === videoId && prev.generation === generation
+ ? { ...prev, pages: [...prev.pages, page] }
: prev,
);
} catch (error) {
@@ -91,5 +100,11 @@ export const useEventCommentsPages = (
hasNext: cursor !== null,
loadMore,
isLoadingMore,
+ reset: () =>
+ setExtra((prev) => ({
+ videoId,
+ generation: prev.generation + 1,
+ pages: [],
+ })),
};
};
diff --git a/apps/mobile/src/features/event/api/use-event-video-sheet.ts b/apps/mobile/src/features/event/api/use-event-video-sheet.ts
index cb728c3f..a0b9a9f9 100644
--- a/apps/mobile/src/features/event/api/use-event-video-sheet.ts
+++ b/apps/mobile/src/features/event/api/use-event-video-sheet.ts
@@ -1,9 +1,14 @@
import { useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import type { EventVideoCommentResponseDto } from "../../../shared/api/sdk";
+import { useProfileQuery } from "../../profile/api/use-profile-query";
+import type { BlockTarget } from "../../user-block/model/user-block";
import { useAutoDismissToast } from "../../video-actions/model/use-auto-dismiss-toast";
import type { EventLocationSelection } from "../model/event-location";
import { deactivateEvent, stepBackEvent } from "../model/event-selection";
import {
eventVideoInteraction,
+ removeCommentsByAuthor,
type EventVideoInteraction,
} from "../model/event-video-cache";
import {
@@ -11,6 +16,7 @@ import {
eventInteractionErrorMessage,
trimmedCommentContent,
} from "../model/event-video-view";
+import { seedDetail } from "./event-video-mutations";
import {
useEventCommentsPages,
type EventCommentsPagesResult,
@@ -60,8 +66,25 @@ export interface EventVideoSheet extends EventVideoDetailResult {
/** 전송 — trim 1~500자 판정 실패·잠금·진행 중이면 무시 (D6) */
submitComment: () => void;
submitDisabled: boolean;
- /** 실패 안내 3초 토스트 — null이면 미표시 (D10) */
+ /** 실패·차단 완료 안내 3초 토스트 — null이면 미표시 (D10 · MSG-570) */
toast: string | null;
+ /**
+ * 댓글 행 길게 누르기의 차단 대상 (MSG-570 기준 10) — 내 댓글(작성자 닉네임 = getMe 닉네임,
+ * A4)은 null이라 행이 눌리지 않는다
+ */
+ commentBlockTarget: (
+ comment: EventVideoCommentResponseDto,
+ ) => BlockTarget | null;
+ /** 길게 누른 타인 댓글 — "사용자 차단" 1행 액션시트의 대상. null이면 닫힘 */
+ commentMenu: BlockTarget | null;
+ openCommentMenu: (target: BlockTarget) => void;
+ closeCommentMenu: () => void;
+ /** 확인 다이얼로그 대상 — 액션시트에서 "사용자 차단"을 고르면 채워진다 */
+ blockTarget: BlockTarget | null;
+ confirmBlockFromMenu: () => void;
+ closeBlockDialog: () => void;
+ /** 차단 성공 — 상세 캐시 seed(그 작성자 댓글 제거) + 이어받은 페이지 리셋 + 토스트 (기준 11) */
+ onBlocked: (blockedUserId: number) => void;
/**
* `‹`·`✕` — `use-event-home`의 `handlers.back/close`와 같은 모듈 액션. 시트 스위치의
* 접촉면 예산(≤8줄) 때문에 prop 주입 대신 여기서 묶는다 (D11·D13)
@@ -98,6 +121,29 @@ export const useEventVideoSheet = (videoId: number): EventVideoSheet => {
const interaction = detail === null ? null : eventVideoInteraction(detail);
+ // 댓글 작성자 차단 (MSG-570 기준 10·11)
+ const queryClient = useQueryClient();
+ const { data: me } = useProfileQuery();
+ const [commentMenu, setCommentMenu] = useState(null);
+ const [blockTarget, setBlockTarget] = useState(null);
+ const commentBlockTarget = (
+ comment: EventVideoCommentResponseDto,
+ ): BlockTarget | null =>
+ comment.authorNickname === me?.nickname
+ ? null
+ : { userId: comment.authorId, nickname: comment.authorNickname };
+ // 제출한 userId로 처리 — 요청 중 다이얼로그를 닫거나 다른 작성자를 고르면 `blockTarget`은
+ // 이미 null·다른 값이다 (codex 리뷰 P2, createComment의 videoId 대조와 같은 레이스 차단)
+ const onBlocked = (blockedUserId: number) => {
+ setBlockTarget(null);
+ // 상세 invalidate 금지(조회수 부작용) — seed + 페이지 리셋으로만 목록에서 뺀다 (A5)
+ seedDetail(queryClient, videoId, (previous) =>
+ removeCommentsByAuthor(previous, blockedUserId),
+ );
+ comments.reset();
+ setToast("차단했어요");
+ };
+
const pressHelpful = () => {
if (detail === null || detail.interactionLocked || toggleHelpful.isPending)
return;
@@ -124,6 +170,17 @@ export const useEventVideoSheet = (videoId: number): EventVideoSheet => {
createComment.isPending ||
!canSubmitComment(draft),
toast,
+ commentBlockTarget,
+ commentMenu,
+ openCommentMenu: setCommentMenu,
+ closeCommentMenu: () => setCommentMenu(null),
+ blockTarget,
+ confirmBlockFromMenu: () => {
+ setBlockTarget(commentMenu);
+ setCommentMenu(null);
+ },
+ closeBlockDialog: () => setBlockTarget(null),
+ onBlocked,
back: stepBackEvent,
close: deactivateEvent,
};
diff --git a/apps/mobile/src/features/event/model/event-video-cache.test.ts b/apps/mobile/src/features/event/model/event-video-cache.test.ts
index 008665a4..9be92acf 100644
--- a/apps/mobile/src/features/event/model/event-video-cache.test.ts
+++ b/apps/mobile/src/features/event/model/event-video-cache.test.ts
@@ -6,6 +6,7 @@ import {
import {
appendComment,
eventVideoInteraction,
+ removeCommentsByAuthor,
seedHelpful,
} from "./event-video-cache";
@@ -48,6 +49,35 @@ describe("appendComment — 작성 응답 seed (AC 6)", () => {
});
});
+describe("removeCommentsByAuthor — 댓글 작성자 차단 seed (MSG-570 기준 11)", () => {
+ it("그 authorId의 댓글만 첫 페이지에서 빠지고 commentCount는 그대로다 — 서버도 줄이지 않는다", () => {
+ const detail = {
+ ...EVENT_VIDEO_DETAIL,
+ commentCount: 3,
+ comments: {
+ ...EVENT_VIDEO_DETAIL.comments,
+ comments: [
+ eventComment(1, { authorId: 7 }),
+ eventComment(2, { authorId: 99 }),
+ eventComment(3, { authorId: 7 }),
+ ],
+ },
+ };
+
+ const seeded = removeCommentsByAuthor(detail, 7);
+
+ expect(seeded.comments.comments.map((c) => c.commentId)).toEqual([2]);
+ expect(seeded.commentCount).toBe(3);
+ expect(seeded.comments.hasNext).toBe(detail.comments.hasNext);
+ });
+
+ it("그 작성자의 댓글이 없으면 상세가 그대로다 (경계)", () => {
+ expect(removeCommentsByAuthor(EVENT_VIDEO_DETAIL, 99)).toEqual(
+ EVENT_VIDEO_DETAIL,
+ );
+ });
+});
+
describe("eventVideoInteraction — interactionLocked 파생 (AC 7)", () => {
it("잠금이 아니면 도움돼요·입력이 활성이고 placeholder는 입력 안내다", () => {
expect(eventVideoInteraction(EVENT_VIDEO_DETAIL)).toEqual({
diff --git a/apps/mobile/src/features/event/model/event-video-cache.ts b/apps/mobile/src/features/event/model/event-video-cache.ts
index 3a84bd91..86608c0a 100644
--- a/apps/mobile/src/features/event/model/event-video-cache.ts
+++ b/apps/mobile/src/features/event/model/event-video-cache.ts
@@ -46,6 +46,24 @@ export const appendComment = (
};
};
+/**
+ * 댓글 작성자 차단 seed (MSG-570 기준 11) — 첫 페이지에서 그 `authorId`의 댓글을 뺀다.
+ * `commentCount`는 **그대로다** — 서버도 차단으로 카운트를 줄이지 않는다(MSG-569 확정).
+ * 이어받은 페이지는 호출부가 `reset()`으로 비운다. 상세 invalidate는 금지(조회수 부작용).
+ */
+export const removeCommentsByAuthor = (
+ detail: EventVideoDetailResponseDto,
+ authorId: number,
+): EventVideoDetailResponseDto => ({
+ ...detail,
+ comments: {
+ ...detail.comments,
+ comments: detail.comments.comments.filter(
+ (comment) => comment.authorId !== authorId,
+ ),
+ },
+});
+
export interface EventVideoInteraction {
helpfulDisabled: boolean;
inputDisabled: boolean;
diff --git a/apps/mobile/src/features/event/model/event-video-card.test.ts b/apps/mobile/src/features/event/model/event-video-card.test.ts
index 62dcfdaa..73300e7b 100644
--- a/apps/mobile/src/features/event/model/event-video-card.test.ts
+++ b/apps/mobile/src/features/event/model/event-video-card.test.ts
@@ -17,6 +17,7 @@ const video = (
createdAt: "2026-09-02T11:58:00+09:00",
helpfulCount: 1,
commentCount: 2,
+ uploaderId: 7,
...over,
});
diff --git a/apps/mobile/src/features/event/model/location-videos-query.parity.test.ts b/apps/mobile/src/features/event/model/location-videos-query.parity.test.ts
index 88d5cae1..f5595903 100644
--- a/apps/mobile/src/features/event/model/location-videos-query.parity.test.ts
+++ b/apps/mobile/src/features/event/model/location-videos-query.parity.test.ts
@@ -41,6 +41,7 @@ const video = (videoId: number): EventLocationVideoResponseDto => ({
createdAt: "2026-09-02T11:58:00+09:00",
helpfulCount: 1,
commentCount: 2,
+ uploaderId: 7,
});
const page = (
diff --git a/apps/mobile/src/features/event/ui/event-video-comment-row.tsx b/apps/mobile/src/features/event/ui/event-video-comment-row.tsx
index d8a5e75a..0a400f30 100644
--- a/apps/mobile/src/features/event/ui/event-video-comment-row.tsx
+++ b/apps/mobile/src/features/event/ui/event-video-comment-row.tsx
@@ -1,4 +1,4 @@
-import { Text, View } from "react-native";
+import { Pressable, Text, View } from "react-native";
import { Avatar } from "@fillmap/ui-native";
import type { EventVideoCommentResponseDto } from "../../../shared/api/sdk";
import { formatRelativeTime } from "../../../shared/format";
@@ -6,15 +6,33 @@ import { formatRelativeTime } from "../../../shared/format";
/**
* 댓글 행 (MSG-562 D5, Figma 15794:822 `comments`) — 아바타 폴백(첫 글자, DTO에 프로필
* 이미지 없음 — 오탐 방지 5) + 닉네임 + 상대시간(우측) + 본문. 웹 `EventVideoComments.tsx` 참조본.
+ * **길게 누르기**(MSG-570 기준 10) — `onLongPress`가 있을 때만 Pressable이고(타인 댓글),
+ * 스크린리더에는 같은 동작을 접근성 액션 "longpress"로 노출한다. 내 댓글은 `onLongPress`가
+ * 없어 평범한 View다.
*/
interface EventVideoCommentRowProps {
comment: EventVideoCommentResponseDto;
+ onLongPress?: () => void;
}
export const EventVideoCommentRow = ({
comment,
+ onLongPress,
}: EventVideoCommentRowProps) => (
-
+ {
+ if (event.nativeEvent.actionName === "longpress") onLongPress?.();
+ }}
+ className="flex-row gap-xs"
+ >
@@ -30,5 +48,5 @@ export const EventVideoCommentRow = ({
{comment.content}
-
+
);
diff --git a/apps/mobile/src/features/event/ui/event-video-sheet-content.tsx b/apps/mobile/src/features/event/ui/event-video-sheet-content.tsx
index c613c8e2..aa534458 100644
--- a/apps/mobile/src/features/event/ui/event-video-sheet-content.tsx
+++ b/apps/mobile/src/features/event/ui/event-video-sheet-content.tsx
@@ -1,7 +1,15 @@
import { Pressable, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Heart, MessageCircle } from "lucide-react-native";
import { semantic } from "@fillmap/design-tokens";
-import { Button, Toast, cx } from "@fillmap/ui-native";
+import {
+ ActionSheet,
+ ActionSheetItem,
+ Button,
+ Toast,
+ cx,
+} from "@fillmap/ui-native";
+import { BlockUserDialog } from "../../user-block/ui/block-user-dialog";
import type { HomeSheetContentContext } from "../../map-home/ui/home-sheet";
import { SheetHeader } from "../../map-home/ui/sheet-header";
import { SheetScrollView } from "../../map-home/ui/sheet-scroll-view";
@@ -25,6 +33,8 @@ import { EventVideoPlayer } from "./event-video-player";
* `eventVideoTitle` 폴백만, 배지 이모지 없음, 댓글 오래된순, 아바타 첫 글자 폴백, 재생
* 컨트롤은 expo-video 네이티브. 헤더 ⋯(유틸리티 메뉴)는 제외 범위.
* 토스트는 시트 안 인라인(`ActionToast` Modal은 3초간 전 화면 터치를 삼켜 재생 조작을 막는다).
+ * **[MSG-570 기준 10·11] 타인 댓글 길게 누르기 → "사용자 차단" 1행 액션시트 → 확인 다이얼로그**
+ * — 성공 시 그 작성자 댓글이 상세 seed로 빠지고 "차단했어요"가 같은 인라인 토스트로 뜬다.
*/
interface EventVideoSheetContentProps extends HomeSheetContentContext {
video: EventVideoInput;
@@ -49,9 +59,18 @@ export const EventVideoSheetContent = ({
submitComment,
submitDisabled,
toast,
+ commentBlockTarget,
+ commentMenu,
+ openCommentMenu,
+ closeCommentMenu,
+ blockTarget,
+ confirmBlockFromMenu,
+ closeBlockDialog,
+ onBlocked,
back,
close,
} = useEventVideoSheet(video.videoId);
+ const insets = useSafeAreaInsets();
const helpfulByMe = detail?.helpfulByMe ?? false;
const helpfulDisabled = interaction?.helpfulDisabled ?? true;
// 키보드 회피 (A2 대안 — R2 실기에서 footer가 자판 뒤에 가려져 채택)
@@ -144,12 +163,20 @@ export const EventVideoSheetContent = ({
) : (
- {comments.comments.map((comment) => (
-
- ))}
+ {comments.comments.map((comment) => {
+ const target = commentBlockTarget(comment);
+ return (
+ openCommentMenu(target)
+ }
+ />
+ );
+ })}
)}
{comments.hasNext && (
@@ -184,6 +211,23 @@ export const EventVideoSheetContent = ({
submitDisabled={submitDisabled}
/>
+
+ {/* 댓글 작성자 차단 (MSG-570 기준 10) — 1행 액션시트 + 공용 확인 다이얼로그 */}
+
+
+
+
>
)}
diff --git a/apps/mobile/src/features/grid-detail/model/grid-videos.test.ts b/apps/mobile/src/features/grid-detail/model/grid-videos.test.ts
index 58399b81..ca6d7aa3 100644
--- a/apps/mobile/src/features/grid-detail/model/grid-videos.test.ts
+++ b/apps/mobile/src/features/grid-detail/model/grid-videos.test.ts
@@ -19,6 +19,7 @@ const globalVideo = (
viewCount: number;
recordedAt: string;
nickname: string;
+ userId: number;
} => ({
videoId,
thumbnailUrl: `https://cdn.test/${videoId}.jpg`,
@@ -26,6 +27,8 @@ const globalVideo = (
viewCount,
recordedAt: "2026-08-16T12:00:00+09:00",
nickname,
+ // 작성자 id — MSG-570 차단 경로 값. videoId와 다른 값으로 두어 혼동 단정을 막는다
+ userId: videoId + 1000,
});
const myVideo = (videoId: number) => ({
@@ -79,6 +82,22 @@ describe("buildGridVideoRows — 소유 판정 (L14)", () => {
});
});
+describe("buildGridVideoRows — 차단 대상 작성자 (MSG-570 기준 2·4)", () => {
+ it("타인 영상 행은 전역 DTO의 userId·닉네임을 차단 대상으로 갖고, 내 영상 행은 null이다 (기준 2·4)", () => {
+ const [mineRow, otherRow] = buildGridVideoRows(
+ [globalVideo(11, "부산러버")],
+ [myVideo(33)],
+ NOW,
+ );
+
+ expect(otherRow).toMatchObject({
+ mine: false,
+ author: { userId: 1011, nickname: "부산러버" },
+ });
+ expect(mineRow).toMatchObject({ mine: true, author: null });
+ });
+});
+
describe("buildGridVideoRows — 행 표시 문구 (L15)", () => {
it('타인 영상 행은 "@닉네임" + "조회 214 · 3일 전"이다 (L15)', () => {
const [row] = buildGridVideoRows([globalVideo(11, "부산러버")], [], NOW);
diff --git a/apps/mobile/src/features/grid-detail/model/grid-videos.ts b/apps/mobile/src/features/grid-detail/model/grid-videos.ts
index 1e7738f3..4330ebd5 100644
--- a/apps/mobile/src/features/grid-detail/model/grid-videos.ts
+++ b/apps/mobile/src/features/grid-detail/model/grid-videos.ts
@@ -3,6 +3,7 @@ import type {
GridVideoResponseDto,
} from "../../../shared/api/sdk";
import { formatRelativeTime, formatViewCount } from "../../../shared/format";
+import type { BlockTarget } from "../../user-block/model/user-block";
/**
* "이 격자의 영상" 목록 병합·소유 판정·행 문구 (MSG-431 L13~L15) — 순수 함수.
@@ -33,6 +34,8 @@ export interface GridVideoRow {
title: string;
/** 행 보조 — "조회 214 · 3일 전" (조회수 미보유면 상대시간만) */
meta: string;
+ /** 차단 대상 작성자 (MSG-570) — 전역 DTO의 `userId`·`nickname`, 내 영상은 null */
+ author: BlockTarget | null;
}
const fromGlobal = (
@@ -47,6 +50,7 @@ const fromGlobal = (
// @는 FE가 붙인다 — 명세 주석 계약 (웹 toFeedItemFromGlobal과 동일)
title: `@${dto.nickname}`,
meta: `조회 ${formatViewCount(dto.viewCount)} · ${formatRelativeTime(dto.recordedAt, now)}`,
+ author: { userId: dto.userId, nickname: dto.nickname },
});
const fromMine = (dto: GridVideoResponseDto, now?: Date): GridVideoRow => ({
@@ -58,6 +62,7 @@ const fromMine = (dto: GridVideoResponseDto, now?: Date): GridVideoRow => ({
title: "내 영상",
// my-videos 응답에 조회수가 없다 — 0으로 꾸미지 않고 상대시간만 낸다
meta: formatRelativeTime(dto.createdAt, now),
+ author: null,
});
/**
diff --git a/apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx b/apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx
index 1f7333fa..0c2fffa7 100644
--- a/apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx
+++ b/apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState, type ReactNode } from "react";
+import { useMemo, type ReactNode } from "react";
import {
ActivityIndicator,
Pressable,
@@ -10,19 +10,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { Share2 } from "lucide-react-native";
import { semantic } from "@fillmap/design-tokens";
-import { BottomSheet, Button, MapIconButton, Toast } from "@fillmap/ui-native";
+import { BottomSheet, Button, MapIconButton } from "@fillmap/ui-native";
import { serverGridIdFromCellId } from "../../../entities/cell/model/cell-id";
-import { useReportVideo } from "../../video-actions/api/use-video-mutations";
-import {
- reportFailureNotice,
- type ReportReasonId,
-} from "../../video-actions/model/report";
-import { useAutoDismissToast } from "../../video-actions/model/use-auto-dismiss-toast";
import { GridMap } from "../../map-home/ui/grid-map";
import { useGridVideosQuery } from "../api/use-grid-videos-query";
import { deriveCellDetail } from "../model/cell-detail";
import { GridVideoRow } from "./grid-video-row";
-import { ReportModal } from "./report-modal";
import { VideoPreview } from "./video-preview";
/** 상세 지도 줌 — 100m 셀이 Figma(14094:4194)처럼 화면 폭의 1/3 규모로 보이는 수준 */
@@ -69,7 +62,8 @@ interface GridDetailScreenProps {
* `deriveCellDetail` mock이다(이 티켓의 목적은 신고 배선이지 격자 상세 실연동이 아니다 —
* 남은 mock은 빌드 리포트에 명시하고 환류한다). 영상 목록은 전역·내 영상 2개 응답의
* videoId 교집합으로 소유를 판정해(`useGridVideosQuery`) 행마다 ⋯ 를 얹고, 도감 갤러리와
- * **같은** 시트를 연다 — 내 영상은 공개 범위·삭제, 타인 영상은 신고하기.
+ * **같은** 시트를 연다 — 내 영상은 공개 범위·삭제, 타인 영상은 신고하기·사용자 차단.
+ * 신고 모달·발사·토스트는 MSG-570에서 `VideoActionsMenu`로 흡수됐다(재생 화면과 공유).
*
* **화면 상단의 격자 단위 ⋯(수정/삭제/신고)는 제거했다** (스펙 신고 배선 결정 4의 "정리"
* 판정 = 제거). 세 항목 모두 영상 단위 메뉴와 의미가 겹치면서 대상이 불분명했다:
@@ -81,9 +75,6 @@ interface GridDetailScreenProps {
export const GridDetailScreen = ({ cellId }: GridDetailScreenProps) => {
const router = useRouter();
const insets = useSafeAreaInsets();
- const [reportTarget, setReportTarget] = useState(null);
- const [reportToast, setReportToast] = useAutoDismissToast();
- const [reportFailure, setReportFailure] = useAutoDismissToast();
const detail = useMemo(() => deriveCellDetail(cellId), [cellId]);
// 라우트 셀 id(구 위경도 스텝 체계)를 서버 격자 id(EPSG:5179)로 옮긴다 — 두 체계를
@@ -91,41 +82,9 @@ export const GridDetailScreen = ({ cellId }: GridDetailScreenProps) => {
const gridId = useMemo(() => serverGridIdFromCellId(cellId), [cellId]);
const videos = useGridVideosQuery(gridId);
- /**
- * 신고 모달 닫기 — **대상과 실패 안내를 함께 비운다**. 실패 안내는 3초 자동 소멸이라
- * 그 사이에 닫고 다른 영상의 신고 모달을 열면 A의 실패 문구가 B의 카드에 뜬다
- * (모달 로컬 선택 상태와 같은 부류의 잔존 — MSG-431 재작업에서 함께 잡았다).
- */
- const closeReport = () => {
- setReportTarget(null);
- setReportFailure(null);
- };
-
- const report = useReportVideo({
- onReported: () => {
- closeReport();
- setReportToast("신고가 접수되었어요");
- },
- onFailed: (error) => {
- const notice = reportFailureNotice(error);
- if (notice.shouldClose) {
- closeReport();
- setReportToast(notice.message);
- return;
- }
- setReportFailure(notice.message);
- },
- });
-
// 유일한 진입 경로(지도 탭)는 항상 인코딩 id를 만든다 — 형식 밖 param은 렌더 없음
if (!detail) return null;
- const handleReportSubmit = (reasonId: ReportReasonId) => {
- if (reportTarget === null) return;
- // 연타 방어는 `mutate`에 씌워진 in-flight 가드가 맡는다 (guardMutate — codex 리뷰 2)
- report.mutate({ videoId: reportTarget, reasonId });
- };
-
return (
{/* 상단 지도 — 시트(top 36%)에 하단이 덮이는 44% 높이라 카메라 중심(22% 지점)이 노출 영역 안에 온다 (AC 2) */}
@@ -232,33 +191,12 @@ export const GridDetailScreen = ({ cellId }: GridDetailScreenProps) => {
// gridId는 목록이 도착했다는 것 자체가 non-null임을 뜻한다(쿼리 게이트)
gridId={gridId ?? ""}
gridLabel={detail.label}
- onReport={(target) => setReportTarget(target.videoId)}
/>
))}
)}
-
- {/* 영상 신고 모달 (AC 9~13) — 대상 videoId는 영상 행이 정한다 */}
-
-
- {/* 신고 접수·중복 안내 토스트 — 홈 인디케이터 위 오버레이, 자동 소멸 */}
- {reportToast !== null && (
-
-
-
- )}
);
};
diff --git a/apps/mobile/src/features/grid-detail/ui/grid-video-row.tsx b/apps/mobile/src/features/grid-detail/ui/grid-video-row.tsx
index f97473b5..d0a77b4c 100644
--- a/apps/mobile/src/features/grid-detail/ui/grid-video-row.tsx
+++ b/apps/mobile/src/features/grid-detail/ui/grid-video-row.tsx
@@ -12,14 +12,13 @@ interface GridVideoRowProps {
gridId: string;
/** 삭제 확인 카드 제목의 격자 라벨 (예: "서면 A-14") */
gridLabel: string;
- /** 신고하기 선택 — 신고 모달은 화면이 소유한다 */
- onReport: (row: GridVideoRowModel) => void;
}
/**
* 격자 상세 "이 격자의 영상" 1행 (MSG-431 신고 배선 결정 2) — `VideoRow` + ⋯ 트리거 +
* 도감 갤러리와 **같은** 액션 시트(`VideoActionsMenu`).
- * `mine`에 따라 시트가 갈린다 — 내 영상은 공개 범위·삭제, 타인 영상은 신고하기.
+ * `mine`에 따라 시트가 갈린다 — 내 영상은 공개 범위·삭제, 타인 영상은 신고하기·사용자 차단.
+ * 신고 모달·차단 다이얼로그·토스트는 `VideoActionsMenu`가 소유한다(MSG-570 — 화면 무접촉).
*
* ⋯는 `VideoRow` 밖 형제로 둔다 — `VideoRow`에 트레일링 슬롯이 없고, ui-native는
* 이 티켓에서 무수정이 원칙이다(`index.ts` diff 0줄 합의). 행 전체는 `flex-1`로 남는다.
@@ -30,12 +29,7 @@ interface GridVideoRowProps {
* 이제 실제 목적지가 붙어 버튼 낭독이 사실이 된다. 행 이름은 `title`+`meta`
* ("내 영상"·"@닉네임 · 3시간 전")가 그대로 읽혀 별도 라벨을 주지 않는다.
*/
-export const GridVideoRow = ({
- row,
- gridId,
- gridLabel,
- onReport,
-}: GridVideoRowProps) => {
+export const GridVideoRow = ({ row, gridId, gridLabel }: GridVideoRowProps) => {
const [menuOpen, setMenuOpen] = useState(false);
return (
@@ -61,7 +55,7 @@ export const GridVideoRow = ({
createdAt: row.recordedAt,
gridLabel,
}}
- onReport={() => onReport(row)}
+ author={row.author ?? undefined}
/>
);
diff --git a/apps/mobile/src/features/profile/ui/profile-screen.tsx b/apps/mobile/src/features/profile/ui/profile-screen.tsx
index e292b6c1..e7501b94 100644
--- a/apps/mobile/src/features/profile/ui/profile-screen.tsx
+++ b/apps/mobile/src/features/profile/ui/profile-screen.tsx
@@ -204,6 +204,11 @@ export const ProfileScreen = () => {
label="신고 관리"
onPress={() => router.navigate("/profile/reports")}
/>
+ {/* MSG-570 기준 12 — 차단한 사용자 목록·해제 */}
+ router.navigate("/profile/blocks")}
+ />
{/* 계정 (기준 7~10) — 앱 버전은 정보 행(› 없음). [MSG-448] 약관 2행도 동작 행 */}
diff --git a/apps/mobile/src/features/report-history/model/report-history.ts b/apps/mobile/src/features/report-history/model/report-history.ts
index 9b4226aa..84085caf 100644
--- a/apps/mobile/src/features/report-history/model/report-history.ts
+++ b/apps/mobile/src/features/report-history/model/report-history.ts
@@ -1,3 +1,7 @@
+import {
+ resolveListState,
+ type ListState,
+} from "../../../shared/api/list-state";
import { formatKstDate } from "../../../shared/format";
/**
@@ -41,22 +45,17 @@ export interface MyReportItem {
}
/** 목록 영역의 배타 상태 (기준 19) */
-export type ReportListState = "loading" | "error" | "empty" | "list";
+export type ReportListState = ListState;
/**
- * 로딩·실패·빈·목록을 한 값으로 판정한다 (기준 19).
- * 실패가 로딩보다 우선한다 — 재시도 왕복 중에 실패 안내와 [다시 시도]가 사라졌다
- * 다시 나타나면 사용자가 재시도를 두 번 누르게 된다.
+ * 로딩·실패·빈·목록을 한 값으로 판정한다 (기준 19) — 판정 규칙은 `shared/api/list-state`
+ * (MSG-570 차단 목록이 같은 판정을 쓰게 되면서 올렸다). 실패가 로딩보다 우선한다.
*/
export const resolveReportListState = (input: {
isPending: boolean;
isError: boolean;
items: readonly MyReportItem[];
-}): ReportListState => {
- if (input.isError) return "error";
- if (input.isPending) return "loading";
- return input.items.length === 0 ? "empty" : "list";
-};
+}): ReportListState => resolveListState(input);
/** 열람용 사유 라벨 — 목록 행이 좁아 제출 화면(REPORT_REASONS)보다 축약형이다 (승인 Q5) */
const REPORT_REASON_LABELS = {
diff --git a/apps/mobile/src/features/user-block/api/invalidate-blocked-content.test.ts b/apps/mobile/src/features/user-block/api/invalidate-blocked-content.test.ts
new file mode 100644
index 00000000..0f1d5745
--- /dev/null
+++ b/apps/mobile/src/features/user-block/api/invalidate-blocked-content.test.ts
@@ -0,0 +1,120 @@
+import { QueryClient, QueryObserver } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * 템플릿 ③ 쿼리 훅(모바일 변형) — 차단·해제 성공 후 무효화 집합 (MSG-570 기준 6).
+ * 목록형 4종은 활성 재조회, 단건 조회 2종(`getPlayback`·`getVideoDetail`)은 무효화만 하고
+ * 재조회하지 않는다(조회수 부작용 · 재생 화면 pop 전 404 플래시 방지).
+ * 생성 키 팩토리는 client-config를 정적으로 끌고 오므로 env를 세운 뒤 동적 import한다.
+ */
+type QueryOptionsModule = typeof import("../../../shared/api/query-options");
+type InvalidateModule = typeof import("./invalidate-blocked-content");
+
+let keys: QueryOptionsModule;
+let invalidateAfterBlockChange: InvalidateModule["invalidateAfterBlockChange"];
+
+beforeEach(async () => {
+ vi.stubEnv("EXPO_PUBLIC_API_BASE_URL", "https://api.test.local");
+ vi.resetModules();
+ keys = await import("../../../shared/api/query-options");
+ ({ invalidateAfterBlockChange } =
+ await import("./invalidate-blocked-content"));
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+});
+
+/**
+ * 활성 관찰자가 붙은 캐시를 만든다 — 무효화가 "재조회까지 하는지"는 관찰자가 있어야 보인다.
+ * staleTime Infinity라 관찰자 구독 자체는 재조회를 일으키지 않는다.
+ */
+const observe = async (
+ queryClient: QueryClient,
+ queryKey: readonly unknown[],
+) => {
+ const options = {
+ queryKey,
+ queryFn: async () => ({ ok: true }),
+ staleTime: Infinity,
+ };
+ await queryClient.fetchQuery(options);
+ return new QueryObserver(queryClient, options).subscribe(() => {});
+};
+
+const seedKeys = () => ({
+ gridGlobalVideos: keys.getGridGlobalVideosQueryKey({
+ path: { gridId: "16858_11420" },
+ }),
+ locationVideos: keys.getLocationVideosInfiniteQueryKey({
+ path: { occurrenceId: 5, locationId: 11 },
+ }),
+ missionVideos: keys.getMissionVideosQueryKey({ path: { missionId: 3 } }),
+ comments: keys.getCommentsQueryKey({
+ path: { videoId: 240347 },
+ query: { cursor: "c1" },
+ }),
+ playback: keys.getPlaybackQueryKey({ path: { videoId: 4102 } }),
+ videoDetail: keys.getVideoDetailQueryKey({ path: { videoId: 240347 } }),
+});
+
+describe("invalidateAfterBlockChange — 차단·해제 성공 후 무효화 집합 (기준 6)", () => {
+ it("격자 전역·위치 영상(infinite)·미션 영상·댓글 페이지는 파라미터와 무관하게 무효화되고 활성 재조회된다 (기준 6)", async () => {
+ const queryClient = new QueryClient();
+ const entries = seedKeys();
+ const unsubscribes = await Promise.all(
+ [
+ entries.gridGlobalVideos,
+ entries.locationVideos,
+ entries.missionVideos,
+ entries.comments,
+ ].map((key) => observe(queryClient, key)),
+ );
+
+ invalidateAfterBlockChange(queryClient);
+
+ for (const key of [
+ entries.gridGlobalVideos,
+ entries.locationVideos,
+ entries.missionVideos,
+ entries.comments,
+ ]) {
+ expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true);
+ expect(queryClient.getQueryState(key)?.fetchStatus).toBe("fetching");
+ }
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
+ queryClient.clear();
+ });
+
+ it("재생 단건·행사 영상 상세는 무효화만 되고 활성 상태여도 재조회하지 않는다 — 조회수 부작용 (기준 6)", async () => {
+ const queryClient = new QueryClient();
+ const entries = seedKeys();
+ const unsubscribes = await Promise.all(
+ [entries.playback, entries.videoDetail].map((key) =>
+ observe(queryClient, key),
+ ),
+ );
+
+ invalidateAfterBlockChange(queryClient);
+
+ for (const key of [entries.playback, entries.videoDetail]) {
+ expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true);
+ expect(queryClient.getQueryState(key)?.fetchStatus).toBe("idle");
+ }
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
+ queryClient.clear();
+ });
+
+ it("차단과 무관한 캐시(차단 목록 포함)는 무효화되지 않는다 (기준 6·14 — 과잉 무효화 방지)", () => {
+ const queryClient = new QueryClient();
+ const blockedUsers = keys.getBlockedUsersQueryKey();
+ queryClient.setQueryData(blockedUsers, { ok: true });
+ queryClient.setQueryData(["unrelated"], { ok: true });
+
+ invalidateAfterBlockChange(queryClient);
+
+ expect(queryClient.getQueryState(blockedUsers)?.isInvalidated).toBe(false);
+ expect(queryClient.getQueryState(["unrelated"])?.isInvalidated).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/features/user-block/api/invalidate-blocked-content.ts b/apps/mobile/src/features/user-block/api/invalidate-blocked-content.ts
new file mode 100644
index 00000000..d4a97f78
--- /dev/null
+++ b/apps/mobile/src/features/user-block/api/invalidate-blocked-content.ts
@@ -0,0 +1,59 @@
+import type { QueryClient } from "@tanstack/react-query";
+import {
+ getCommentsQueryKey,
+ getGridGlobalVideosQueryKey,
+ getLocationVideosQueryKey,
+ getMissionVideosQueryKey,
+ getPlaybackQueryKey,
+ getVideoDetailQueryKey,
+} from "../../../shared/api/query-options";
+
+/**
+ * 차단·해제 성공 후 무효화 집합 (MSG-570 기준 6) — 순수 배선 함수
+ * (`video-actions/api/invalidate-video-queries` 관례: 모바일은 훅 렌더 테스트가 없어 분리).
+ *
+ * 서버가 차단 관계로 필터하는 목록(MSG-569 6종) 중 앱이 캐시하는 것을 모두 `_id` 부분 키로
+ * 무효화한다 — 생성 `createQueryKey`가 무한 쿼리에는 `_infinite`를 **추가**만 하므로
+ * `getLocationVideos` infinite도 같은 부분 키에 매칭된다.
+ *
+ * 단건 조회 2종은 `refetchType: "none"` — 다음 마운트에서만 재조회한다:
+ * - `getPlayback`·`getVideoDetail`은 재조회가 조회수를 올린다(MSG-431·MSG-562).
+ * - 재생 화면에서 차단 직후 활성 재조회하면 pop 전에 404 안내가 플래시한다.
+ * 차단 목록(`getBlockedUsers`)은 여기 없다 — 해제는 seed(`removeBlockedUser`)로 반영한다.
+ */
+const invalidateAllOf = (
+ queryClient: QueryClient,
+ [key]: [{ _id: string }],
+ refetchType?: "none",
+): void => {
+ void queryClient.invalidateQueries({
+ queryKey: [{ _id: key._id }],
+ refetchType,
+ });
+};
+
+export const invalidateAfterBlockChange = (queryClient: QueryClient): void => {
+ invalidateAllOf(
+ queryClient,
+ getGridGlobalVideosQueryKey({ path: { gridId: "" } }),
+ );
+ invalidateAllOf(
+ queryClient,
+ getLocationVideosQueryKey({ path: { occurrenceId: 0, locationId: 0 } }),
+ );
+ invalidateAllOf(
+ queryClient,
+ getMissionVideosQueryKey({ path: { missionId: 0 } }),
+ );
+ invalidateAllOf(queryClient, getCommentsQueryKey({ path: { videoId: 0 } }));
+ invalidateAllOf(
+ queryClient,
+ getPlaybackQueryKey({ path: { videoId: 0 } }),
+ "none",
+ );
+ invalidateAllOf(
+ queryClient,
+ getVideoDetailQueryKey({ path: { videoId: 0 } }),
+ "none",
+ );
+};
diff --git a/apps/mobile/src/features/user-block/api/use-blocked-users-query.ts b/apps/mobile/src/features/user-block/api/use-blocked-users-query.ts
new file mode 100644
index 00000000..36539a50
--- /dev/null
+++ b/apps/mobile/src/features/user-block/api/use-blocked-users-query.ts
@@ -0,0 +1,33 @@
+import { useQuery } from "@tanstack/react-query";
+import { unwrapEnvelope } from "../../../shared/api/envelope";
+import { getBlockedUsersOptions } from "../../../shared/api/query-options";
+import type { BlockedUserResponseDto } from "../../../shared/api/sdk";
+
+/**
+ * 차단한 사용자 목록 (MSG-570 기준 13) — `GET /api/users/me/blocks`, 서버 최신순·페이지 없음.
+ * 반환 형태는 report-history 훅과 동형이라 화면이 같은 4상태 스위치(`resolveBlockListState`)를 탄다.
+ * 해제 성공은 뮤테이션이 이 캐시를 seed로 갱신한다 — 여기서 재조회하지 않는다.
+ */
+export interface BlockedUsersQueryResult {
+ items: readonly BlockedUserResponseDto[];
+ isPending: boolean;
+ isError: boolean;
+ refetch: () => void;
+}
+
+/** 참조가 매번 바뀌지 않도록 모듈 상수로 고정 */
+const NO_ITEMS: readonly BlockedUserResponseDto[] = [];
+
+export const useBlockedUsersQuery = (): BlockedUsersQueryResult => {
+ const query = useQuery({
+ ...getBlockedUsersOptions(),
+ select: unwrapEnvelope,
+ });
+
+ return {
+ items: query.data ?? NO_ITEMS,
+ isPending: query.isPending,
+ isError: query.isError,
+ refetch: () => void query.refetch(),
+ };
+};
diff --git a/apps/mobile/src/features/user-block/api/use-user-block-mutations.ts b/apps/mobile/src/features/user-block/api/use-user-block-mutations.ts
new file mode 100644
index 00000000..aff8b89f
--- /dev/null
+++ b/apps/mobile/src/features/user-block/api/use-user-block-mutations.ts
@@ -0,0 +1,54 @@
+import {
+ useMutation,
+ useQueryClient,
+ type MutationKey,
+ type QueryClient,
+ type UseMutationOptions,
+} from "@tanstack/react-query";
+import { guardMutate } from "../../video-actions/api/video-mutations";
+import {
+ blockUserMutationOptions,
+ unblockUserMutationOptions,
+ USER_BLOCK_MUTATION_KEYS,
+} from "./user-block-mutations";
+
+/**
+ * 사용자 차단·해제 훅 (MSG-570 기준 8) — 옵션 팩토리(`user-block-mutations`)에 QueryClient를
+ * 물리는 얇은 층. `mutate`에 in-flight 가드(`guardMutate`, video-actions 선례)를 씌우고
+ * `mutateAsync`를 감춘다 — 같은 키의 요청이 진행 중이면 재발사가 무시된다.
+ * 콜백은 훅 레벨 옵션 — mutate per-call 콜백은 관찰자 언마운트 시 유실된다(MSG-325).
+ */
+
+/** 가드 래핑 공통부 — 두 훅이 같은 형태라 여기서 한 번만 (제네릭이라 입력 타입이 넓어지지 않는다) */
+const useGuardedMutation = (
+ mutationKey: MutationKey,
+ build: (
+ queryClient: QueryClient,
+ ) => UseMutationOptions,
+) => {
+ const queryClient = useQueryClient();
+ const mutation = useMutation(build(queryClient));
+ const { mutateAsync, ...guarded } = mutation;
+ void mutateAsync;
+ return {
+ ...guarded,
+ mutate: guardMutate(queryClient, mutationKey, mutation.mutate),
+ };
+};
+
+export const useBlockUser = (callbacks?: {
+ onBlocked?: (userId: number) => void;
+ onError?: () => void;
+}) =>
+ useGuardedMutation(USER_BLOCK_MUTATION_KEYS.block, (queryClient) =>
+ blockUserMutationOptions({
+ queryClient,
+ onBlocked: callbacks?.onBlocked,
+ onError: callbacks?.onError,
+ }),
+ );
+
+export const useUnblockUser = (callbacks?: { onError?: () => void }) =>
+ useGuardedMutation(USER_BLOCK_MUTATION_KEYS.unblock, (queryClient) =>
+ unblockUserMutationOptions({ queryClient, onError: callbacks?.onError }),
+ );
diff --git a/apps/mobile/src/features/user-block/api/user-block-mutations.test.ts b/apps/mobile/src/features/user-block/api/user-block-mutations.test.ts
new file mode 100644
index 00000000..c6cd866b
--- /dev/null
+++ b/apps/mobile/src/features/user-block/api/user-block-mutations.test.ts
@@ -0,0 +1,261 @@
+import { MutationObserver, QueryClient } from "@tanstack/react-query";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ envelopeResponse,
+ errorEnvelope,
+} from "../../../test/envelope-response";
+
+/**
+ * 템플릿 ③ 쿼리 훅(모바일 변형) — 사용자 차단·해제 mutation의 요청·성공 처리·실패 계약
+ * (MSG-570 기준 4·6·7·8·14·15). RN 렌더 인프라가 없어 훅이 그대로 넘기는 옵션 객체를
+ * MutationObserver로 구동한다 (video-mutations.test 관례).
+ */
+
+const API_BASE = "https://api.test.local";
+const USER_ID = 42;
+const GRID_ID = "16858_11420";
+
+const loadModule = async () => {
+ vi.stubEnv("EXPO_PUBLIC_API_BASE_URL", API_BASE);
+ vi.resetModules();
+ const mutations = await import("./user-block-mutations");
+ const keys = await import("../../../shared/api/query-options");
+ const { registerApiErrorInterceptor } =
+ await import("../../../shared/api/error-interceptor");
+ registerApiErrorInterceptor();
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return { mutations, keys, queryClient };
+};
+
+interface ReceivedRequest {
+ method: string;
+ pathname: string;
+}
+
+const stubFetch = (
+ route: (request: Request) => Response | Promise,
+) => {
+ const received: ReceivedRequest[] = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: Request) => {
+ received.push({
+ method: input.method,
+ pathname: new URL(input.url).pathname,
+ });
+ return route(input);
+ }),
+ );
+ return received;
+};
+
+/** 응답을 붙잡아 두는 fetch 스텁 — in-flight 창을 열어 연타 가드를 관찰한다 */
+const stubGatedFetch = (respond: () => Response) => {
+ let release: (() => void) | undefined;
+ let markStarted: (() => void) | undefined;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const started = new Promise((resolve) => {
+ markStarted = resolve;
+ });
+ const received = stubFetch(async () => {
+ markStarted?.();
+ await gate;
+ return respond();
+ });
+ return { received, started, release: () => release?.() };
+};
+
+const blockedEnvelope = (userIds: number[]) => ({
+ developCode: 0,
+ message: "ok",
+ data: userIds.map((userId) => ({
+ userId,
+ nickname: `user${userId}`,
+ profileImageUrl: null,
+ blockedAt: "2026-09-11T02:30:00",
+ })),
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ vi.resetModules();
+});
+
+describe("사용자 차단 mutation (기준 4·6·7·8)", () => {
+ it("[차단] → 작성자 userId를 경로로 POST /api/users/{userId}/block이 1회 발사된다 (기준 4)", async () => {
+ const { mutations, queryClient } = await loadModule();
+ const received = stubFetch(() => envelopeResponse(null));
+ const observer = new MutationObserver(
+ queryClient,
+ mutations.blockUserMutationOptions({ queryClient }),
+ );
+
+ await observer.mutate({ userId: USER_ID });
+
+ expect(received).toEqual([
+ { method: "POST", pathname: `/api/users/${USER_ID}/block` },
+ ]);
+ });
+
+ it("성공하면 격자 전역 영상 캐시가 무효화되고 완료 콜백이 불린다 (기준 5·6)", async () => {
+ const { mutations, keys, queryClient } = await loadModule();
+ const gridKey = keys.getGridGlobalVideosQueryKey({
+ path: { gridId: GRID_ID },
+ });
+ queryClient.setQueryData(gridKey, { ok: true });
+ stubFetch(() => envelopeResponse(null));
+ const onBlocked = vi.fn();
+ const observer = new MutationObserver(
+ queryClient,
+ mutations.blockUserMutationOptions({ queryClient, onBlocked }),
+ );
+
+ await observer.mutate({ userId: USER_ID });
+
+ expect(queryClient.getQueryState(gridKey)?.isInvalidated).toBe(true);
+ expect(onBlocked).toHaveBeenCalledTimes(1);
+ });
+
+ it("성공하면 차단 목록 캐시도 무효화되고 완료 콜백이 제출한 userId를 받는다 — 목록 화면 재진입 30초 안에도 새 행이 보인다 (기준 12·13, codex P2)", async () => {
+ const { mutations, keys, queryClient } = await loadModule();
+ const listKey = keys.getBlockedUsersQueryKey();
+ queryClient.setQueryData(listKey, blockedEnvelope([3]));
+ stubFetch(() => envelopeResponse(null));
+ const onBlocked = vi.fn();
+ const observer = new MutationObserver(
+ queryClient,
+ mutations.blockUserMutationOptions({ queryClient, onBlocked }),
+ );
+
+ await observer.mutate({ userId: USER_ID });
+
+ expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(true);
+ expect(onBlocked).toHaveBeenCalledWith(USER_ID);
+ });
+
+ it("실패하면 어떤 쿼리도 무효화되지 않고 완료 콜백 없이 실패 콜백만 불린다 — 다이얼로그가 남는다 (기준 7)", async () => {
+ const { mutations, keys, queryClient } = await loadModule();
+ const gridKey = keys.getGridGlobalVideosQueryKey({
+ path: { gridId: GRID_ID },
+ });
+ queryClient.setQueryData(gridKey, { ok: true });
+ stubFetch(() => errorEnvelope(9999, "차단 실패", 500));
+ const onBlocked = vi.fn();
+ const onError = vi.fn();
+ const observer = new MutationObserver(
+ queryClient,
+ mutations.blockUserMutationOptions({ queryClient, onBlocked, onError }),
+ );
+
+ await expect(observer.mutate({ userId: USER_ID })).rejects.toThrow();
+
+ expect(queryClient.getQueryState(gridKey)?.isInvalidated).toBe(false);
+ expect(onBlocked).not.toHaveBeenCalled();
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ it("차단 요청이 진행 중인 동안 재발사가 무시된다 — 중복 POST 없음 (기준 8)", async () => {
+ const { mutations, queryClient } = await loadModule();
+ const { received, started, release } = stubGatedFetch(() =>
+ envelopeResponse(null),
+ );
+ const observer = new MutationObserver(
+ queryClient,
+ mutations.blockUserMutationOptions({ queryClient }),
+ );
+ const { guardMutate } =
+ await import("../../video-actions/api/video-mutations");
+ const mutate = vi.fn();
+ const guarded = guardMutate(
+ queryClient,
+ mutations.USER_BLOCK_MUTATION_KEYS.block,
+ mutate,
+ );
+
+ const inFlight = observer.mutate({ userId: USER_ID });
+ await started;
+ guarded({ userId: USER_ID });
+
+ expect(mutate).not.toHaveBeenCalled();
+ expect(received).toHaveLength(1);
+
+ release();
+ await inFlight;
+ guarded({ userId: USER_ID });
+
+ expect(mutate).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("차단 해제 mutation (기준 14·15)", () => {
+ it("[차단 해제] → 확인 없이 DELETE /api/users/{userId}/block이 발사된다 (기준 14)", async () => {
+ const { mutations, queryClient } = await loadModule();
+ const received = stubFetch(() => envelopeResponse(null));
+ const observer = new MutationObserver(
+ queryClient,
+ mutations.unblockUserMutationOptions({ queryClient }),
+ );
+
+ await observer.mutate({ userId: USER_ID });
+
+ expect(received).toEqual([
+ { method: "DELETE", pathname: `/api/users/${USER_ID}/block` },
+ ]);
+ });
+
+ it("성공하면 그 행이 차단 목록 캐시에서 즉시 제거되고(재조회 없음) 콘텐츠 목록은 무효화된다 (기준 14)", async () => {
+ const { mutations, keys, queryClient } = await loadModule();
+ const listKey = keys.getBlockedUsersQueryKey();
+ queryClient.setQueryData(listKey, blockedEnvelope([3, USER_ID, 7]));
+ const gridKey = keys.getGridGlobalVideosQueryKey({
+ path: { gridId: GRID_ID },
+ });
+ queryClient.setQueryData(gridKey, { ok: true });
+ stubFetch(() => envelopeResponse(null));
+ const observer = new MutationObserver(
+ queryClient,
+ mutations.unblockUserMutationOptions({ queryClient }),
+ );
+
+ await observer.mutate({ userId: USER_ID });
+
+ expect(
+ queryClient
+ .getQueryData<{ data: { userId: number }[] }>(listKey)
+ ?.data.map((u) => u.userId),
+ ).toEqual([3, 7]);
+ expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(false);
+ expect(queryClient.getQueryState(gridKey)?.isInvalidated).toBe(true);
+ });
+
+ it("실패하면 행이 남고 무효화 없이 실패 콜백만 불린다 (기준 15)", async () => {
+ const { mutations, keys, queryClient } = await loadModule();
+ const listKey = keys.getBlockedUsersQueryKey();
+ queryClient.setQueryData(listKey, blockedEnvelope([3, USER_ID]));
+ const gridKey = keys.getGridGlobalVideosQueryKey({
+ path: { gridId: GRID_ID },
+ });
+ queryClient.setQueryData(gridKey, { ok: true });
+ stubFetch(() => errorEnvelope(9999, "해제 실패", 500));
+ const onError = vi.fn();
+ const observer = new MutationObserver(
+ queryClient,
+ mutations.unblockUserMutationOptions({ queryClient, onError }),
+ );
+
+ await expect(observer.mutate({ userId: USER_ID })).rejects.toThrow();
+
+ expect(
+ queryClient
+ .getQueryData<{ data: { userId: number }[] }>(listKey)
+ ?.data.map((u) => u.userId),
+ ).toEqual([3, USER_ID]);
+ expect(queryClient.getQueryState(gridKey)?.isInvalidated).toBe(false);
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/mobile/src/features/user-block/api/user-block-mutations.ts b/apps/mobile/src/features/user-block/api/user-block-mutations.ts
new file mode 100644
index 00000000..b10a21d9
--- /dev/null
+++ b/apps/mobile/src/features/user-block/api/user-block-mutations.ts
@@ -0,0 +1,96 @@
+import type {
+ MutationKey,
+ QueryClient,
+ UseMutationOptions,
+} from "@tanstack/react-query";
+import {
+ blockMutation,
+ getBlockedUsersQueryKey,
+ unblockMutation,
+} from "../../../shared/api/query-options";
+import type { ApiResponseDtoListBlockedUserResponseDto } from "../../../shared/api/sdk";
+import { removeBlockedUser } from "../model/user-block";
+import { invalidateAfterBlockChange } from "./invalidate-blocked-content";
+
+/**
+ * 사용자 차단·해제 mutation 옵션 (MSG-570 기준 4·6·7·14·15) — "옵션 팩토리 + 얇은 훅"
+ * (MSG-426 관례). 테스트는 이 객체를 `MutationObserver`로 직접 구동한다.
+ * 얇은 훅은 `use-user-block-mutations.ts`가 소유한다.
+ */
+
+// 생성 팩토리는 mutationFn을 항상 채운다 — UseMutationOptions 타입만 optional이라 !로 좁힌다
+const blockFn = blockMutation().mutationFn!;
+const unblockFn = unblockMutation().mutationFn!;
+
+/** in-flight 판정용 키 — `guardMutate`가 이 키로 진행 중 mutation을 센다 (기준 8) */
+export const USER_BLOCK_MUTATION_KEYS = {
+ block: ["user-block", "block"],
+ unblock: ["user-block", "unblock"],
+} as const satisfies Record;
+
+export interface UserBlockInput {
+ userId: number;
+}
+
+/**
+ * 차단 (기준 4·6·7) — `POST /api/users/{userId}/block`. 성공 시 콘텐츠 목록과 차단 목록을 무효화하고
+ * 완료 콜백을 부른다. 실패 시 아무것도 무효화하지 않아 다이얼로그가 열린 채 재시도할 수 있다.
+ */
+export const blockUserMutationOptions = ({
+ queryClient,
+ onBlocked,
+ onError,
+}: {
+ queryClient: QueryClient;
+ /** 성공 콜백은 **제출한** userId를 받는다 — 호출부가 다이얼로그 상태(닫힘·다른 대상)에 기대지 않게 */
+ onBlocked?: (userId: number) => void;
+ onError?: () => void;
+}): UseMutationOptions<
+ Awaited>,
+ Error,
+ UserBlockInput
+> => ({
+ mutationKey: USER_BLOCK_MUTATION_KEYS.block,
+ mutationFn: (input, context) =>
+ blockFn({ path: { userId: input.userId } }, context),
+ onSuccess: (_data, variables) => {
+ invalidateAfterBlockChange(queryClient);
+ // 차단 목록은 seed하지 않는다(blockedAt·프로필 이미지는 서버만 안다) — 30초 stale 창 안에
+ // 목록 화면으로 돌아와도 새 행이 보이도록 무효화 (codex 리뷰 P2)
+ void queryClient.invalidateQueries({ queryKey: getBlockedUsersQueryKey() });
+ onBlocked?.(variables.userId);
+ },
+ onError: () => onError?.(),
+});
+
+/**
+ * 해제 (기준 14·15) — `DELETE /api/users/{userId}/block`. 성공 시 차단 목록 캐시에서 그 행을
+ * seed로 제거하고(재조회 없음 — 목록은 최신순 단순 배열이라 로컬 필터가 정본과 같다)
+ * 콘텐츠 목록을 무효화한다. 실패 시 행이 남는다.
+ */
+export const unblockUserMutationOptions = ({
+ queryClient,
+ onError,
+}: {
+ queryClient: QueryClient;
+ onError?: () => void;
+}): UseMutationOptions<
+ Awaited>,
+ Error,
+ UserBlockInput
+> => ({
+ mutationKey: USER_BLOCK_MUTATION_KEYS.unblock,
+ mutationFn: (input, context) =>
+ unblockFn({ path: { userId: input.userId } }, context),
+ onSuccess: (_data, { userId }) => {
+ queryClient.setQueryData(
+ getBlockedUsersQueryKey(),
+ (previous) =>
+ previous === undefined
+ ? previous
+ : { ...previous, data: removeBlockedUser(previous.data, userId) },
+ );
+ invalidateAfterBlockChange(queryClient);
+ },
+ onError: () => onError?.(),
+});
diff --git a/apps/mobile/src/features/user-block/model/user-block.test.ts b/apps/mobile/src/features/user-block/model/user-block.test.ts
new file mode 100644
index 00000000..33d4d5f9
--- /dev/null
+++ b/apps/mobile/src/features/user-block/model/user-block.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+import type { BlockedUserResponseDto } from "../../../shared/api/sdk";
+import {
+ BLOCK_CONFIRM_DESCRIPTION,
+ blockConfirmTitle,
+ removeBlockedUser,
+ resolveBlockListState,
+ toBlockedUserRowView,
+} from "./user-block";
+
+/**
+ * 템플릿 ① 순수 로직 — 사용자 차단 확인 문구·차단 목록 4상태 판정·행 표시 파생·해제 seed
+ * (MSG-570 기준 3·13·14·16). 화면은 이 파생만 읽는 얇은 스위치다.
+ */
+
+const blocked = (
+ overrides: Partial = {},
+): BlockedUserResponseDto => ({
+ userId: 42,
+ nickname: "서면탐험가",
+ profileImageUrl: null,
+ blockedAt: "2026-09-11T02:30:00",
+ ...overrides,
+});
+
+describe("blockConfirmTitle — 차단 확인 다이얼로그 제목 (기준 3)", () => {
+ it("'@닉네임 님을 차단할까요?' 형식이다 — @는 FE가 붙인다 (기준 3)", () => {
+ expect(blockConfirmTitle("서면탐험가")).toBe(
+ "@서면탐험가 님을 차단할까요?",
+ );
+ });
+
+ it("본문은 영상·댓글이 사라진다는 안내와 해제 위치를 함께 알린다 (기준 3)", () => {
+ expect(BLOCK_CONFIRM_DESCRIPTION).toBe(
+ "이 사용자의 영상과 댓글이 더 이상 보이지 않아요. 프로필 > 차단한 사용자에서 해제할 수 있어요",
+ );
+ });
+});
+
+describe("resolveBlockListState — 로딩·실패·빈·목록 4상태 판정 (기준 16)", () => {
+ it("실패 > 로딩 > 빈 > 목록 순으로 판정한다 — 재조회 중 실패도 실패다 (기준 16)", () => {
+ const item = blocked();
+
+ expect(
+ resolveBlockListState({ isPending: true, isError: true, items: [item] }),
+ ).toBe("error");
+ expect(
+ resolveBlockListState({ isPending: true, isError: false, items: [] }),
+ ).toBe("loading");
+ expect(
+ resolveBlockListState({ isPending: false, isError: false, items: [] }),
+ ).toBe("empty");
+ expect(
+ resolveBlockListState({
+ isPending: false,
+ isError: false,
+ items: [item],
+ }),
+ ).toBe("list");
+ });
+});
+
+describe("toBlockedUserRowView — 차단 1건 → 행 표시 재료 (기준 13)", () => {
+ it("닉네임·이니셜(첫 글자)·아바타 URL·차단일 YYYY.MM.DD(KST)가 파생된다 (기준 13)", () => {
+ expect(
+ toBlockedUserRowView(
+ blocked({
+ profileImageUrl: "https://cdn.test/42.jpg",
+ blockedAt: "2026-09-11T15:30:00",
+ }),
+ ),
+ ).toEqual({
+ nickname: "서면탐험가",
+ initial: "서",
+ avatarUrl: "https://cdn.test/42.jpg",
+ blockedAt: "2026.09.12",
+ });
+ });
+
+ it("프로필 이미지가 없으면 avatarUrl이 undefined라 Avatar가 이니셜 폴백을 그린다 (기준 13)", () => {
+ expect(toBlockedUserRowView(blocked()).avatarUrl).toBeUndefined();
+ });
+});
+
+describe("removeBlockedUser — 해제 성공 seed용 필터 (기준 14)", () => {
+ it("해제한 userId의 행만 빠지고 서버 순서는 유지된다 (기준 14)", () => {
+ const list = [
+ blocked({ userId: 3 }),
+ blocked({ userId: 42 }),
+ blocked({ userId: 7 }),
+ ];
+
+ expect(removeBlockedUser(list, 42).map((u) => u.userId)).toEqual([3, 7]);
+ });
+
+ it("목록에 없는 userId면 그대로다 (경계)", () => {
+ const list = [blocked({ userId: 3 })];
+
+ expect(removeBlockedUser(list, 99)).toEqual(list);
+ });
+});
diff --git a/apps/mobile/src/features/user-block/model/user-block.ts b/apps/mobile/src/features/user-block/model/user-block.ts
new file mode 100644
index 00000000..64db8e0f
--- /dev/null
+++ b/apps/mobile/src/features/user-block/model/user-block.ts
@@ -0,0 +1,57 @@
+import {
+ resolveListState,
+ type ListState,
+} from "../../../shared/api/list-state";
+import type { BlockedUserResponseDto } from "../../../shared/api/sdk";
+import { formatKstDate } from "../../../shared/format";
+
+/**
+ * 사용자 차단 순수 모델 (MSG-570 기준 3·13·14·16) — 플랫폼 API·react·라우터 무의존.
+ * 확인 다이얼로그 문구, 차단 목록의 4상태 판정·행 표시 파생, 해제 성공 seed 필터를 둔다.
+ * 화면(`ui/`)은 이 파생만 읽는 얇은 스위치다 (report-history와 같은 3층 구조).
+ */
+
+/** 차단 대상 — 진입점 3곳(격자 상세 행·재생 화면·이벤트 댓글)이 넘기는 최소 정보 */
+export interface BlockTarget {
+ userId: number;
+ nickname: string;
+}
+
+/** 확인 다이얼로그 제목 (기준 3) — @는 FE가 붙인다(닉네임 원문 계약) */
+export const blockConfirmTitle = (nickname: string): string =>
+ `@${nickname} 님을 차단할까요?`;
+
+export const BLOCK_CONFIRM_DESCRIPTION =
+ "이 사용자의 영상과 댓글이 더 이상 보이지 않아요. 프로필 > 차단한 사용자에서 해제할 수 있어요";
+
+/** 목록 영역의 배타 상태 (기준 16) */
+export type BlockListState = ListState;
+
+/** 실패 > 로딩 > 빈 > 목록 — 판정 규칙은 `shared/api/list-state`(report-history와 공유) */
+export const resolveBlockListState = resolveListState;
+
+/** 행 1개가 그리는 재료 (기준 13) */
+export interface BlockedUserRowView {
+ nickname: string;
+ /** 아바타 이니셜 폴백 — 닉네임 첫 글자 */
+ initial: string;
+ /** 없으면 undefined — `Avatar`가 이니셜 폴백을 그린다 */
+ avatarUrl: string | undefined;
+ /** 차단일 "YYYY.MM.DD"(KST) — 서버 blockedAt은 타임존 마커 없는 UTC */
+ blockedAt: string;
+}
+
+export const toBlockedUserRowView = (
+ dto: BlockedUserResponseDto,
+): BlockedUserRowView => ({
+ nickname: dto.nickname,
+ initial: dto.nickname.slice(0, 1),
+ avatarUrl: dto.profileImageUrl ?? undefined,
+ blockedAt: formatKstDate(dto.blockedAt),
+});
+
+/** 해제 성공 seed (기준 14) — 서버 순서를 유지한 채 그 행만 뺀다 */
+export const removeBlockedUser = (
+ list: readonly BlockedUserResponseDto[],
+ userId: number,
+): BlockedUserResponseDto[] => list.filter((user) => user.userId !== userId);
diff --git a/apps/mobile/src/features/user-block/ui/block-user-dialog.tsx b/apps/mobile/src/features/user-block/ui/block-user-dialog.tsx
new file mode 100644
index 00000000..0e935095
--- /dev/null
+++ b/apps/mobile/src/features/user-block/ui/block-user-dialog.tsx
@@ -0,0 +1,68 @@
+import { ModalCard, Toast } from "@fillmap/ui-native";
+import { useAutoDismissToast } from "../../video-actions/model/use-auto-dismiss-toast";
+import { useBlockUser } from "../api/use-user-block-mutations";
+import {
+ BLOCK_CONFIRM_DESCRIPTION,
+ blockConfirmTitle,
+ type BlockTarget,
+} from "../model/user-block";
+
+interface BlockUserDialogProps {
+ /** 차단 대상 — null이면 닫힘 */
+ target: BlockTarget | null;
+ /** 취소·딤 탭·Android back — 요청 없이 닫기 (기준 3) */
+ onClose: () => void;
+ /**
+ * 차단 성공 — 호출부가 닫고 토스트·후속(pop 등)을 처리한다 (기준 5). 인자는 **제출한** userId:
+ * 요청 중 다이얼로그가 닫히거나 다른 대상으로 바뀌어도 `target`이 아닌 이 값으로 후속을 처리한다
+ */
+ onBlocked: (userId: number) => void;
+}
+
+/**
+ * 사용자 차단 확인 다이얼로그 (MSG-570 기준 3·4·5·7·8) — 진입점 3곳(격자 상세·재생 화면·
+ * 이벤트 댓글)이 **이 컴포넌트 하나**를 쓴다. 뮤테이션·진행 중·실패 문구를 여기가 소유해
+ * 진입점이 셋이어도 동작은 하나다.
+ * 앱 확인 관례(영상 삭제·로그아웃·탈퇴)대로 `ModalCard` 중앙 카드(A1). 실패 안내는 카드 안
+ * `Toast` — RN Modal 경계상 호스트 트리 토스트는 이 창 뒤에 가린다(삭제 다이얼로그 동형).
+ */
+export const BlockUserDialog = ({
+ target,
+ onClose,
+ onBlocked,
+}: BlockUserDialogProps) => {
+ const [failure, setFailure] = useAutoDismissToast();
+
+ // 닫힘 경로 전부에서 실패 안내를 비운다 — 다음 대상의 다이얼로그에 지난 실패가 남지 않게
+ const close = () => {
+ setFailure(null);
+ onClose();
+ };
+ const block = useBlockUser({
+ onBlocked: (userId) => {
+ setFailure(null);
+ onBlocked(userId);
+ },
+ onError: () => setFailure("차단하지 못했어요. 잠시 후 다시 시도해 주세요"),
+ });
+
+ return (
+ {
+ // 연타 방어는 `mutate`의 in-flight 가드가 맡는다 (guardMutate)
+ if (target !== null) block.mutate({ userId: target.userId });
+ }}
+ >
+ {failure !== null && }
+
+ );
+};
diff --git a/apps/mobile/src/features/user-block/ui/blocked-user-row.tsx b/apps/mobile/src/features/user-block/ui/blocked-user-row.tsx
new file mode 100644
index 00000000..326f5f65
--- /dev/null
+++ b/apps/mobile/src/features/user-block/ui/blocked-user-row.tsx
@@ -0,0 +1,51 @@
+import { Text, View } from "react-native";
+import { Avatar, Button } from "@fillmap/ui-native";
+import type { BlockedUserResponseDto } from "../../../shared/api/sdk";
+import { toBlockedUserRowView } from "../model/user-block";
+
+interface BlockedUserRowProps {
+ item: BlockedUserResponseDto;
+ /** 이 행의 해제 요청이 진행 중 — 버튼 비활성 (기준 15) */
+ unblocking: boolean;
+ onUnblock: () => void;
+}
+
+/**
+ * 차단한 사용자 1행 (MSG-570 기준 13) — 아바타(없으면 닉네임 첫 글자) · 닉네임 · 차단일 ·
+ * [차단 해제]. 표시 재료는 `toBlockedUserRowView`가 만든다 — 이 행은 배치만 한다.
+ * 카드형 행은 신고 관리 행 관례(Figma 시안 없음).
+ */
+export const BlockedUserRow = ({
+ item,
+ unblocking,
+ onUnblock,
+}: BlockedUserRowProps) => {
+ const view = toBlockedUserRowView(item);
+
+ return (
+
+
+
+
+ {view.nickname}
+
+
+ {view.blockedAt}
+
+
+
+
+ );
+};
diff --git a/apps/mobile/src/features/user-block/ui/blocked-users-screen.tsx b/apps/mobile/src/features/user-block/ui/blocked-users-screen.tsx
new file mode 100644
index 00000000..4d0be73a
--- /dev/null
+++ b/apps/mobile/src/features/user-block/ui/blocked-users-screen.tsx
@@ -0,0 +1,73 @@
+import { ActivityIndicator, ScrollView, Text, View } from "react-native";
+import { useRouter } from "expo-router";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { semantic } from "@fillmap/design-tokens";
+import { AppHeader } from "@fillmap/ui-native";
+import { DexErrorState } from "../../dex/ui/dex-error-state";
+import { useAutoDismissToast } from "../../video-actions/model/use-auto-dismiss-toast";
+import { ActionToast } from "../../video-actions/ui/action-toast";
+import { useBlockedUsersQuery } from "../api/use-blocked-users-query";
+import { useUnblockUser } from "../api/use-user-block-mutations";
+import { resolveBlockListState } from "../model/user-block";
+import { BlockedUserRow } from "./blocked-user-row";
+
+/**
+ * 차단한 사용자 (MSG-570 기준 13~16) — 프로필 설정에서 push. Figma 시안 없음, 신고 관리
+ * 화면과 같은 앱 관례(`AppHeader` + `ScrollView` + 4상태 스위치). 실연동이라 상시 고지 카드는
+ * 없다(A8). 해제는 확인 없이 발사되고 성공은 캐시 seed로 행이 즉시 빠진다(기준 14).
+ * 실패 상태는 `DexErrorState`(도메인 문구 없는 공용형 — 사용처 4곳째, ui-native 승격 검토 대상)를 쓴다.
+ */
+export const BlockedUsersScreen = () => {
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const [toast, setToast] = useAutoDismissToast();
+ const blocked = useBlockedUsersQuery();
+ const unblock = useUnblockUser({
+ onError: () =>
+ setToast("차단을 해제하지 못했어요. 잠시 후 다시 시도해 주세요"),
+ });
+ const state = resolveBlockListState(blocked);
+
+ return (
+
+ router.back()} />
+
+ {state === "error" ? (
+
+ ) : state === "loading" ? (
+
+
+
+ ) : state === "empty" ? (
+
+ 차단한 사용자가 없어요
+
+ ) : (
+
+ {blocked.items.map((item) => (
+ unblock.mutate({ userId: item.userId })}
+ />
+ ))}
+
+ )}
+
+
+ {/* 해제 실패 안내 (기준 15) — 화면 하단 오버레이, 시트 밖이라 ActionToast 사용 가능 */}
+ setToast(null)} />
+
+ );
+};
diff --git a/apps/mobile/src/features/grid-detail/ui/report-modal.tsx b/apps/mobile/src/features/video-actions/ui/report-modal.tsx
similarity index 97%
rename from apps/mobile/src/features/grid-detail/ui/report-modal.tsx
rename to apps/mobile/src/features/video-actions/ui/report-modal.tsx
index eaf820bc..2f0a9288 100644
--- a/apps/mobile/src/features/grid-detail/ui/report-modal.tsx
+++ b/apps/mobile/src/features/video-actions/ui/report-modal.tsx
@@ -7,11 +7,8 @@ import {
canSubmitReport,
REPORT_REASONS,
type ReportReasonId,
-} from "../../video-actions/model/report";
-import {
- EMPTY_REPORT_FORM,
- reportFormReducer,
-} from "../../video-actions/model/report-form";
+} from "../model/report";
+import { EMPTY_REPORT_FORM, reportFormReducer } from "../model/report-form";
interface ReportModalProps {
visible: boolean;
diff --git a/apps/mobile/src/features/video-actions/ui/video-actions-menu.tsx b/apps/mobile/src/features/video-actions/ui/video-actions-menu.tsx
index 3e0e783e..74f668a2 100644
--- a/apps/mobile/src/features/video-actions/ui/video-actions-menu.tsx
+++ b/apps/mobile/src/features/video-actions/ui/video-actions-menu.tsx
@@ -1,15 +1,20 @@
import { useState } from "react";
+import { BlockUserDialog } from "../../user-block/ui/block-user-dialog";
+import type { BlockTarget } from "../../user-block/model/user-block";
import {
useDeleteVideo,
+ useReportVideo,
useSetVideoVisibility,
} from "../api/use-video-mutations";
import { useVideoVisibilityQuery } from "../api/use-video-visibility-query";
+import { reportFailureNotice, type ReportReasonId } from "../model/report";
import { useAutoDismissToast } from "../model/use-auto-dismiss-toast";
import {
shouldPatchVisibility,
type VideoActionTarget,
} from "../model/video-menu";
import { ActionToast } from "./action-toast";
+import { ReportModal } from "./report-modal";
import { VideoDeleteConfirmDialog } from "./video-delete-confirm-dialog";
import { VideoMoreSheet } from "./video-more-sheet";
@@ -20,20 +25,29 @@ interface VideoActionsMenuProps {
target: VideoActionTarget;
/** 내 영상 여부 — 도감 갤러리는 정의상 항상 true, 격자 상세는 my-videos 교집합 판정 */
mine: boolean;
- /** 신고하기 선택 — 신고 모달은 진입점 화면이 소유한다(타인 영상이 있는 화면만 가진다) */
- onReport?: () => void;
+ /** 타인 영상의 작성자 — "사용자 차단" 대상 (MSG-570). `mine=false`인 진입점만 넘긴다 */
+ author?: BlockTarget;
+ /** 차단 성공 후 — 재생 화면은 이전 화면으로 pop, 목록 화면은 재조회에 맡겨 생략 */
+ onBlocked?: () => void;
}
/**
- * 영상 액션 오버레이 묶음 (MSG-431 S1~S10) — 더보기 시트 + 삭제 확인 다이얼로그 +
- * 실패 토스트와 그 상태·서버 호출을 한 곳에서 소유한다. 도감 갤러리 카드와 격자 상세
- * 영상 행이 이것 하나를 재사용한다(진입점이 둘이어도 동작은 하나여야 한다).
+ * 영상 액션 오버레이 묶음 (MSG-431 S1~S10 · MSG-570) — 더보기 시트 + 삭제 확인 다이얼로그 +
+ * 신고 모달 + 차단 확인 다이얼로그 + 토스트와 그 상태·서버 호출을 한 곳에서 소유한다.
+ * 도감 갤러리 카드·격자 상세 영상 행·재생 화면이 이것 하나를 재사용한다(진입점이 셋이어도
+ * 동작은 하나여야 한다).
+ *
+ * **[MSG-570] 신고 흐름을 진입점 화면에서 이 안으로 흡수했다**(A2 채택) — 재생 화면에도 같은
+ * 시트(신고하기·사용자 차단)가 필요해졌고, 화면마다 신고 모달·토스트를 배선하면 세 화면이
+ * 갈린다(웹 `VideoMoreMenu`가 `ReportDialog`를 소유하는 구조와 동일). 신고 대상은 이 메뉴의
+ * `target.videoId` 하나라 별도 대상 상태가 없다. 부수 효과: 격자 상세의 신고 성공 토스트가
+ * `pointerEvents=none` 오버레이에서 `ActionToast`(탭 해제 Modal)로 바뀐다.
*
* 공개 범위는 **비낙관**이다(승인 Q6): 선택 즉시 시트를 닫고 요청만 보내며, ✓는 움직이지
* 않는다. 성공 시 응답 값이 캐시에 seed돼 다시 열면 ✓가 옮겨져 있고, 실패 시 ✓가 이전
* 상태 그대로라 "이전 상태로 되돌린다"는 요구가 자동 충족된다 — 알림만 토스트가 맡는다.
*
- * 액션 3종은 모두 `guardMutate` in-flight 가드를 통과해 발사된다 — 연타로 중복 요청이
+ * 액션 전부가 `guardMutate` in-flight 가드를 통과해 발사된다 — 연타로 중복 요청이
* 나가면 뒤늦은 실패가 이미 닫힌 화면의 상태를 건드린다(MSG-431 codex 리뷰 1·2).
*
* 삭제는 서버가 정본이다 — 성공 시 목록·통계를 무효화해 재조회로 사라지게 하고(승인 Q5),
@@ -44,11 +58,15 @@ export const VideoActionsMenu = ({
onClose,
target,
mine,
- onReport,
+ author,
+ onBlocked,
}: VideoActionsMenuProps) => {
const [deleteOpen, setDeleteOpen] = useState(false);
+ const [reportOpen, setReportOpen] = useState(false);
+ const [blockTarget, setBlockTarget] = useState(null);
const [actionMessage, setActionMessage] = useAutoDismissToast();
const [deleteMessage, setDeleteMessage] = useAutoDismissToast();
+ const [reportFailure, setReportFailure] = useAutoDismissToast();
// 시트가 열려 있거나 삭제 확인이 떠 있는 동안에만 조회한다 — 삭제 카드 2행도 이 값을 쓴다
const { visibility } = useVideoVisibilityQuery(
@@ -78,6 +96,33 @@ export const VideoActionsMenu = ({
setDeleteMessage("영상을 삭제하지 못했어요. 잠시 후 다시 시도해 주세요."),
});
+ /** 신고 모달 닫기 — 실패 안내를 함께 비운다(삭제와 같은 잔존 방지) */
+ const closeReport = () => {
+ setReportOpen(false);
+ setReportFailure(null);
+ };
+
+ const report = useReportVideo({
+ onReported: () => {
+ closeReport();
+ setActionMessage("신고가 접수되었어요");
+ },
+ onFailed: (error) => {
+ const notice = reportFailureNotice(error);
+ if (notice.shouldClose) {
+ closeReport();
+ setActionMessage(notice.message);
+ return;
+ }
+ setReportFailure(notice.message);
+ },
+ });
+
+ const handleReportSubmit = (reasonId: ReportReasonId) => {
+ // 연타 방어는 `mutate`에 씌워진 in-flight 가드가 맡는다 (guardMutate — codex 리뷰 2)
+ report.mutate({ videoId: target.videoId, reasonId });
+ };
+
return (
<>
{
onClose();
- onReport?.();
+ setReportOpen(true);
+ }}
+ onBlock={() => {
+ onClose();
+ // 작성자 미확보(진입점이 안 넘김)면 아무것도 띄우지 않는다 — 대상 없는 차단은 없다
+ if (author !== undefined) setBlockTarget(author);
}}
/>
@@ -117,6 +167,26 @@ export const VideoActionsMenu = ({
failureMessage={deleteMessage}
/>
+ {/* 영상 신고 모달 (MSG-317 AC 9~13) — 대상은 이 메뉴의 영상 하나 */}
+
+
+ {/* 사용자 차단 확인 (MSG-570 기준 3~8) — 성공 토스트는 재생 화면 pop 시 함께 사라진다(A3) */}
+ setBlockTarget(null)}
+ onBlocked={() => {
+ setBlockTarget(null);
+ setActionMessage("차단했어요");
+ onBlocked?.();
+ }}
+ />
+
setActionMessage(null)}
diff --git a/apps/mobile/src/features/video-actions/ui/video-more-sheet.tsx b/apps/mobile/src/features/video-actions/ui/video-more-sheet.tsx
index 6257eb38..9ced240e 100644
--- a/apps/mobile/src/features/video-actions/ui/video-more-sheet.tsx
+++ b/apps/mobile/src/features/video-actions/ui/video-more-sheet.tsx
@@ -18,6 +18,8 @@ interface VideoMoreSheetProps {
onSelectVisibility: (visibility: VideoVisibility) => void;
onDelete: () => void;
onReport: () => void;
+ /** 타인 영상 "사용자 차단" (MSG-570 기준 2) — 신고하기 아래 */
+ onBlock: () => void;
}
/**
@@ -26,7 +28,7 @@ interface VideoMoreSheetProps {
* 있는 일은 하나의 목록이어야 하기 때문이고, `mine`으로만 갈라진다.
*
* 내 영상: "공개 범위" 섹션 라벨 + 전체 공개/나만 보기 2행(현재 상태에만 ✓) + 구분선 +
- * "영상 삭제"(빨간 글씨). 타인 영상: "신고하기" 1행.
+ * "영상 삭제"(빨간 글씨). 타인 영상: "신고하기" + "사용자 차단"(MSG-570) 2행.
* 딤·시트 쉘·취소 버튼·홈 인디케이터 인셋은 `ui-native/ActionSheet`가 소유한다.
*
* **행에 아이콘을 지정하지 않는 것이 정본이다** — Figma 14856:515/518/521은 텍스트만이며,
@@ -40,6 +42,7 @@ export const VideoMoreSheet = ({
onSelectVisibility,
onDelete,
onReport,
+ onBlock,
}: VideoMoreSheetProps) => {
const insets = useSafeAreaInsets();
@@ -64,7 +67,10 @@ export const VideoMoreSheet = ({
>
) : (
-
+ <>
+
+
+ >
)}
);
diff --git a/apps/mobile/src/features/video-playback/ui/video-player-screen.tsx b/apps/mobile/src/features/video-playback/ui/video-player-screen.tsx
index 3cb3c3d6..1ed68cec 100644
--- a/apps/mobile/src/features/video-playback/ui/video-player-screen.tsx
+++ b/apps/mobile/src/features/video-playback/ui/video-player-screen.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import {
ActivityIndicator,
Pressable,
@@ -10,7 +11,10 @@ import { useRouter } from "expo-router";
import { VideoView, useVideoPlayer } from "expo-video";
import { semantic } from "@fillmap/design-tokens";
import { AppHeader } from "@fillmap/ui-native";
+import type { VideoPlaybackResponseDto } from "../../../shared/api/sdk";
import { formatDuration, formatViewCount } from "../../../shared/format";
+import { VideoActionsMenu } from "../../video-actions/ui/video-actions-menu";
+import { VideoMoreButton } from "../../video-actions/ui/video-more-button";
import { useVideoPlaybackQuery } from "../api/use-video-playback-query";
import {
playbackAccessNotice,
@@ -40,6 +44,11 @@ interface VideoPlayerScreenProps {
* 부르지 않는 이유는 `upload-video-preview.tsx` JSDoc에 실측으로 기록돼 있다 — `useVideoPlayer`가
* 소스 변경 시 플레이어를 재생성하므로 교체를 더하면 같은 소스를 두 번 로드하며 경합한다.
* 재생성 시 setup 콜백이 다시 돌아 자동 재생이 성립한다 (승인 추정 5).
+ *
+ * **[MSG-570 기준 9] 타인 영상(`mine=0`)에만 헤더 우측 ⋯** — 격자 상세와 같은 시트
+ * (신고하기·사용자 차단, `VideoActionsMenu`). 차단 성공 시 토스트 없이 이전 화면으로 pop한다
+ * (A3 — 화면이 사라지면 토스트 Modal도 함께 사라진다). 내 영상에는 ⋯가 없다 — 공개 범위·삭제는
+ * 목록 화면(도감·격자 상세)의 몫이고 재생 화면 `mine`은 표기용 신호일 뿐이다.
*/
export const VideoPlayerScreen = ({
videoId,
@@ -49,6 +58,8 @@ export const VideoPlayerScreen = ({
const insets = useSafeAreaInsets();
const { playback, isPending, isError, error, retry } =
useVideoPlaybackQuery(videoId);
+ // 타인 영상 + 응답 도착 후에만 — 작성자 `userId`·`nickname`이 응답에서 온다
+ const actionsTarget = !mine && playback ? playback : null;
const uri = playback?.playbackUrl ?? null;
const player = useVideoPlayer(uri, (instance) => {
@@ -76,6 +87,15 @@ export const VideoPlayerScreen = ({
router.back()}
+ right={
+ actionsTarget !== null ? (
+ router.back()}
+ />
+ ) : undefined
+ }
/>
@@ -143,3 +163,40 @@ export const VideoPlayerScreen = ({
);
};
+
+/**
+ * 타인 영상 헤더 ⋯ + 액션 시트 (MSG-570 기준 9) — 격자 상세 행과 같은 메뉴.
+ * 시트·다이얼로그·토스트가 전부 Modal이라 헤더 `right` 슬롯 안에서 열어도 렌더 위치가 같다.
+ * 화면 컴포넌트에서 분리한 이유는 react-doctor 복잡도 상한(no-high-complexity-react-function).
+ */
+const PlaybackActions = ({
+ videoId,
+ playback,
+ onBlocked,
+}: {
+ videoId: number;
+ playback: VideoPlaybackResponseDto;
+ onBlocked: () => void;
+}) => {
+ const [menuOpen, setMenuOpen] = useState(false);
+ return (
+ <>
+ setMenuOpen(true)} />
+ setMenuOpen(false)}
+ mine={false}
+ target={{
+ videoId,
+ gridId: playback.gridId,
+ thumbnailUrl: playback.thumbnailUrl,
+ durationSec: playback.durationSec,
+ createdAt: playback.recordedAt,
+ gridLabel: playbackTitle(playback),
+ }}
+ author={{ userId: playback.userId, nickname: playback.nickname }}
+ onBlocked={onBlocked}
+ />
+ >
+ );
+};
diff --git a/apps/mobile/src/shared/api/list-state.ts b/apps/mobile/src/shared/api/list-state.ts
new file mode 100644
index 00000000..f9a2d2fe
--- /dev/null
+++ b/apps/mobile/src/shared/api/list-state.ts
@@ -0,0 +1,18 @@
+/** 목록 영역의 배타 4상태 — 실패 > 로딩 > 빈 > 목록 */
+export type ListState = "loading" | "error" | "empty" | "list";
+
+/**
+ * 쿼리 상태 + 항목 수 → 목록 4상태 판정 (MSG-448 report-history 기준 19에서 시작,
+ * MSG-570 차단 목록이 두 번째 용례 — 두 feature가 같은 판정을 쓰게 돼 shared로 올린다).
+ * 실패가 로딩보다 우선한다 — 재시도 왕복 중에 실패 안내와 [다시 시도]가 사라졌다
+ * 다시 나타나면 사용자가 재시도를 두 번 누르게 된다. 순수 함수 — 플랫폼·react 무의존.
+ */
+export const resolveListState = (input: {
+ isPending: boolean;
+ isError: boolean;
+ items: readonly unknown[];
+}): ListState => {
+ if (input.isError) return "error";
+ if (input.isPending) return "loading";
+ return input.items.length === 0 ? "empty" : "list";
+};
diff --git a/apps/mobile/src/test/event-video-fixture.ts b/apps/mobile/src/test/event-video-fixture.ts
index c47ad473..91c00907 100644
--- a/apps/mobile/src/test/event-video-fixture.ts
+++ b/apps/mobile/src/test/event-video-fixture.ts
@@ -34,6 +34,7 @@ export const EVENT_VIDEO_DETAIL: EventVideoDetailResponseDto = {
durationSec: 5,
recordedAt: "2026-09-01T01:39:10Z",
createdAt: "2026-09-01T01:39:12Z",
+ uploaderId: 7,
uploaderNickname: "강정만두",
interactionLocked: false,
helpfulCount: 1,
diff --git a/apps/web/openapi/api-docs.json b/apps/web/openapi/api-docs.json
index 168a9246..cab501d3 100644
--- a/apps/web/openapi/api-docs.json
+++ b/apps/web/openapi/api-docs.json
@@ -1 +1 @@
-{"openapi":"3.1.0","info":{"title":"FillMap API","description":"FillMap API 문서","version":"v1"},"servers":[{"url":"https://api.fillmap.kr","description":"Generated server url"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"비밀번호 (Password)","description":"비밀번호 상태·변경·재설정 API. 상태·변경은 로그인 필수, 재설정 2종은 비로그인이다."},{"name":"행사 운영자 계정 (Org Account)","description":"담당자 정보 조회·수정과 아이디 변경 요청. 행사 운영자 전용이다."},{"name":"행사 (Events)","description":"지도에서 누른 격자를 행사 위치로 해석하는 역조회 API."},{"name":"미션 영상 (Mission Videos)","description":"미션 상세 하단 \"이 미션의 영상\" 목록 API — 그 미션의 대상 격자에서 미션 기간에 촬영된 공개 영상."},{"name":"이벤트 (Event)","description":"이벤트 열람 인원 heartbeat·조회 API."},{"name":"영상 (Video)","description":"영상 업로드·교체·삭제 API. 업로드는 presigned URL 발급 → S3 직접 업로드 → 메타데이터 저장 순서다."},{"name":"인증 (Auth)","description":"회원가입·로그인·소셜 로그인·토큰 재발급 API. 이 그룹의 엔드포인트는 인증 없이 호출한다."},{"name":"행사 운영자 콘솔 (Org)","description":"행사 운영자 전용 조회 API — 승인 이벤트 목록."},{"name":"알림 (Notification)","description":"FCM 푸시 토큰 등록/갱신·해제 API."},{"name":"장소 검색 (Search)","description":"장소명 자유 텍스트 검색 — 카카오 로컬 키워드 검색 실시간 프록시 + 격자 ID 합성."},{"name":"행사 운영자 계정 발급 요청 (Org Account Request)","description":"계정이 없는 행사 운영자가 발급을 신청하는 공개 폼. 비로그인 호출이다."},{"name":"격자 (Grid)","description":"개인 도감 색칠 격자 조회 API — 로그인 사용자가 점령한 격자만 반환한다.\n\n격자는 EPSG:5179 미터 평면에서 100m 로 나눈 셀이다(2026-08-08 MSG-347 전까지는 위경도 등간격 근사였다). gridId 포맷 `\"{grid_y}_{grid_x}\"` 와 이 API 들의 요청·응답 구조는 그대로지만 **값은 전면 교체됐다** (같은 장소가 `41642_110458` 에서 `19422_9582` 로 바뀌었다). 예전 gridId 를 저장해 둔 클라이언트는 빈 결과를 받으므로 캐시를 비워야 한다.\n\n셀은 위경도 축과 평행하지 않다(자오선 수렴 최대 약 1.6도). 지도에 그릴 때 남서·북동 2점으로 만든 직사각형을 쓰면 어긋나므로 **꼭짓점 4점 폴리곤**으로 그린다. 화면에 보이는 격자 범위를 구할 때도 2점이 아니라 꼭짓점 4점의 min/max 를 써야 가장자리 셀이 빠지지 않는다.\n\n클라이언트가 같은 격자를 계산하려면 서버와 **글자 단위로 같은 proj4 정의**를 써야 한다: `+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs`. 대조용 전국 샘플 200건은 서버 레포 `src/test/resources/fixtures/grid-epsg5179-samples.json` 에 있다."},{"name":"알림 (Notification)","description":"받은 알림 목록 조회와 읽음 처리 API."},{"name":"인증-개발용 (Auth Dev)","description":"로컬/dev 전용 — 소셜 로그인을 실제 소셜 토큰 없이 백엔드에서 테스트. 운영(prod) 미노출."},{"name":"도감 (Collection)","description":"개인 도감 요약 조회 API — 로그인 사용자의 점령·영상·방문 행정동 집계."},{"name":"관리자 행사 등재 심사 (Admin Event Submission)","description":"행사 등재 신청을 검토하고 승인·반려하는 API (MSG-500). ADMIN 권한 필수."},{"name":"관리자 승인 행사 (Admin Approved Event)","description":"승인된 행사의 상태별 조회와 노출 중지 API (MSG-500). ADMIN 권한 필수."},{"name":"격자 상세 (Grid Videos)","description":"격자를 탭했을 때 그 격자의 영상 조회 API — 내 영상 리스트·전역 대표 영상·전역 인기 목록."},{"name":"AI 경로 추천 (Routes)","description":"자연어 한 문장과 뷰포트로 활성 미션·행사·장소 검색 실조회 후보에 방문 순서와 이유를 붙여 돌려준다."},{"name":"구역 (Zone)","description":"구역(\"서면\" 등)의 이름과 격자 사각형 범위. 검색바에서 구역으로 지도를 옮기거나 구역 범위를 오버레이로 그릴 때 쓴다 — 격자 표시명(\"서면 A-14\")은 서버가 계산해 격자 응답에 함께 싣는다."},{"name":"행사 (Events)","description":"행사 위치의 영상 업로드·피드·상세 API."},{"name":"미션 (Missions)","description":"지도 오버레이용 활성 미션 목록·내 진행도·미션 상세 조회 API."},{"name":"알림 (Notification)","description":"카테고리별 알림 수신 설정 조회/토글 API."},{"name":"행정동 (Region)","description":"좌표를 포함하는 행정동을 우리 region_code 체계로 판정하는 역지오코딩 API."},{"name":"인기 검색어 (Trending)","description":"사용자 검색어 일별 집계 기반 인기 검색어 순위 — 오늘+어제 합산 TOP 10."},{"name":"전역 탐색 (Region Explore)","description":"행정동 축으로 전역 공개 콘텐츠를 탐색하는 API — 지도 홈 패널·전체 보기 격자 썸네일 뷰·검색 무입력 전체 지역 리스트."},{"name":"관리자 신고 처리 (Admin Report)","description":"접수된 영상 신고의 열람·승인·기각과 블라인드 해제·단건 확인 API (MSG-195). ADMIN 권한 필수."},{"name":"사용자 (User)","description":"계정 관리 API. 인증 필수 — 본인 계정만 대상이다."},{"name":"뱃지 (Badge)","description":"뱃지 API — 내 뱃지 목록 조회 · 대표 뱃지 집합 교체."},{"name":"친구 (Friend)","description":"고정 친구 코드 기반 친구 관계 API — 코드·요청·수락·거절·삭제 (MSG-185), 친구 목록·친구 프로필 조회 (MSG-186), 친구 도감 레이어(격자 뷰포트·격자 영상 목록, MSG-187, 축소 시야의 행정 단위 집계는 MSG-356). 인증 필수."},{"name":"행사 (Events)","description":"지도 홈 행사 칩·이벤트 헤더·행사 위치 목록 조회 API."},{"name":"관리자 행사 운영자 계정 (Admin Org Account)","description":"계정 발급 요청 검토와 계정 발급·초기 비밀번호 재발송 API (MSG-499), 아이디 변경 요청 심사 (MSG-500). ADMIN 권한 필수."},{"name":"행사 등재 신청 (Org Submission)","description":"행사 운영자가 행사를 신청하고 반려본을 고쳐 다시 낸다."},{"name":"신고 (Report)","description":"영상 신고 접수 API (MSG-192). 인증 필수."},{"name":"행사 (Events)","description":"행사 영상의 댓글·도움돼요 API."},{"name":"핫구역 (HotZone)","description":"최근 48시간 방문(업로드) 신호 상위 격자 조회 API — 개인화 없는 공용 목록."}],"paths":{"/api/videos/{videoId}":{"get":{"tags":["영상 (Video)"],"summary":"단건 영상 재생 조회","description":"영상 하나의 표시용 메타와 재생본 presigned GET URL을 발급한다. 소유자·타인 모두 조회할 수 있으나 삭제·블라인드(타인)는 404, 비공개(타인)·친구만 공개(비친구)는 403이다. READY가 아니면 playbackUrl은 null이다. 비로그인도 조회할 수 있으며 전체 공개 영상만 통과한다 — 나머지는 타인이 요청할 때와 같은 응답으로 거절된다.","operationId":"getPlayback","parameters":[{"name":"videoId","in":"path","description":"재생할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoPlaybackResponseDto"}}}}}},"put":{"tags":["영상 (Video)"],"summary":"영상 교체","description":"기존 영상을 새 파일로 교체한다. 좌표를 생략하면 격자를 유지하고 파일만 교체하며, 좌표를 보내면 기존과 같은 격자여야 한다(다르면 거부). 교체 직후 상태는 UPLOADED다.","operationId":"replace","parameters":[{"name":"videoId","in":"path","description":"교체할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1001}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoReplaceRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoReplaceResponseDto"}}}}}},"delete":{"tags":["영상 (Video)"],"summary":"영상 삭제","description":"영상을 삭제한다. 해당 격자의 내 영상이 모두 사라지면 점령이 롤백(색칠 해제)된다.","operationId":"delete","parameters":[{"name":"videoId","in":"path","description":"삭제할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1001}],"responses":{"200":{"description":"OK"}}}},"/api/users/me/profile-image":{"put":{"tags":["사용자 (User)"],"summary":"프로필 이미지 변경 확정","description":"presign 으로 올린 pending 키를 확정해 프로필 이미지를 교체하고 갱신된 프로필을 반환한다. 내 pending 경로가 아니거나 확장자 없는 키는 1401, S3 에 실제로 없는 키는 1402, 실측 크기가 5MB 를 넘으면 1413. 교체된 이전 이미지는 응답 후 정리된다.","operationId":"updateProfileImage","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileImageUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}},"delete":{"tags":["사용자 (User)"],"summary":"프로필 이미지 제거","description":"프로필 이미지를 기본 상태(null)로 되돌린다. 이미 기본 상태여도 성공한다(멱등). 응답은 변경 확정과 같은 프로필 형태다.","operationId":"removeProfileImage","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/nickname":{"put":{"tags":["사용자 (User)"],"summary":"닉네임 수정","description":"닉네임(2~20자)을 교체하고 변경 후 프로필을 반환한다. 중복 닉네임은 허용된다.","operationId":"updateNickname","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NicknameUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/marketing-consent":{"put":{"tags":["사용자 (User)"],"summary":"마케팅 정보 수신 동의 변경","description":"가입 후 설정 화면에서 마케팅 수신 동의를 켜거나 끈다. 이미 저장된 값과 같은 값을 다시 보내도 성공하며, 이때 서버가 보관하는 마지막 변경 시각은 갱신되지 않는다(멱등). 응답은 변경 후 동의 상태다 — 위치정보 사용 동의 변경과 같은 구조다.","operationId":"updateMarketingConsent","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketingConsentUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}}},"/api/users/me/location-consent":{"put":{"tags":["사용자 (User)"],"summary":"위치정보 사용 동의 켜기","description":"위치기반서비스 이용 동의를 켜고 변경 후 프로필을 반환한다. 첫 로그인 온보딩의 동의 제출과 프로필 화면이 이 엔드포인트 하나를 공용으로 쓴다.\n\n이 동의는 철회할 수 없다 — consented=false 요청은 1400 으로 거절된다. 되돌리려면 계정을 삭제해야 하며, 이는 다른 필수 약관 동의와 같은 규칙이다. 이미 켜진 상태에서 다시 켜는 요청은 성공하고, 이때 서버가 보관하는 동의 시각은 갱신되지 않는다(멱등).","operationId":"updateLocationConsent","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationConsentUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/consents":{"get":{"tags":["사용자 (User)"],"summary":"가입 약관 동의 상태 조회","description":"로그인 직후 동의 게이트를 띄울지 판별하는 재료다. 항목별 동의 여부 5종과 필수 4항목 완료 여부(requiredCompleted)를 함께 반환한다 — 필수 항목 목록이 늘어도 클라이언트가 조립을 고치지 않도록 서버가 계산한다.\n\n위치기반서비스 항목(locationTerms)은 프로필 화면의 위치정보 사용 동의와 같은 한 값이다. 이 동의는 철회할 수 없으므로 한 번 true 가 되면 되돌아가지 않고, 필수 동의를 마친 사용자에게 게이트가 다시 뜨는 일도 없다. 동의 시각은 서버에만 보관하고 응답에 싣지 않는다.","operationId":"getConsentStatus","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}},"put":{"tags":["사용자 (User)"],"summary":"가입 약관 동의 제출","description":"가입 게이트의 \"동의하고 시작하기\" 제출이다. 필수 4항목(만 14세 이상·서비스 이용약관·개인정보 수집·이용·위치기반서비스 이용약관)은 true 여야 하고 마케팅만 선택이다 — 하나라도 false 거나 누락이면 400 이며 이때 아무 항목도 저장되지 않는다.\n\n같은 내용을 다시 보내도 성공한다(멱등). 재제출이 필수 4항목의 최초 동의 시각을 덮지 않고, 마케팅만 값이 실제로 달라질 때 변경 시각이 갱신된다. 제출은 위치정보 사용 동의도 함께 켜므로 프로필 화면의 위치 동의와 값이 하나다. 응답은 제출 후 동의 상태다.","operationId":"submitConsents","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentSubmitRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}}},"/api/event-videos/{videoId}/helpful":{"put":{"tags":["행사 (Events)"],"summary":"행사 영상 도움돼요 추가","description":"이 영상에 도움돼요를 누른다. 사용자당 한 번이고 이미 누른 상태에서 다시 불러도 성공하며 수가 늘지 않는다 — 네트워크 재시도가 수를 흔들지 않도록 PUT 으로 둔 이유다.\n\n응답의 helpfulCount 는 처리 후 다시 센 값이라 그 사이 다른 사람이 누른 것도 반영된다.\n\n아카이브된 행사(종료 30일 후)에서는 409 + developCode 13422 다 — 유예 기간까지는 계속 누를 수 있다. 상세에 보이지 않는 영상은 404 + 13406 이다.","operationId":"addHelpful","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoHelpfulResponseDto"}}}}}},"delete":{"tags":["행사 (Events)"],"summary":"행사 영상 도움돼요 취소","description":"누른 도움돼요를 되돌린다. 누른 적이 없어도 실패하지 않는다(멱등).\n\n아카이브된 행사(종료 30일 후)에서는 409 + developCode 13422 다 — 유예 기간까지는 취소할 수 있다. 상세에 보이지 않는 영상은 404 + 13406 이다.","operationId":"removeHelpful","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoHelpfulResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/notification":{"put":{"tags":["행사 (Events)"],"summary":"행사 알림 구독 토글","description":"행사 회차 단위로 알림을 켜고 끈다. 이벤트에는 참여 절차가 없어 구독이 사용자와 행사가 맺는 관계의 전부다. 같은 값을 반복 요청해도 같은 결과로 성공한다.\n\n응답의 enabled 는 저장된 구독 행의 존재가 아니라 **노출 상태**다 — 구독 행이 있으면서 회차가 예정이거나 진행 중일 때만 true 이고, 종료된 회차는 행이 남아 있어도 false 다(종료 시점부터 즉시 OFF, 정리 배치를 기다리지 않는다).\n\n종료된 행사(업로드 유예·아카이브)에 켜기를 요청하면 409 + developCode 13422 다 — 시작 알림이 이미 지나 받을 것이 없기 때문이다. 끄기는 상태와 무관하게 언제나 성공한다. 없는 회차이거나 아직 노출 기간 전인 예정 회차면 404 + developCode 13404 다.\n\n실제 발송은 이 구독 위에 알림 설정의 EVENT 카테고리 스위치가 겹쳐 결정된다 — 카테고리를 끈 사용자에게는 구독이 켜져 있어도 발송되지 않는다.","operationId":"updateSubscription","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventNotificationUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventNotificationResponseDto"}}}}}}},"/api/badges/featured":{"put":{"tags":["뱃지 (Badge)"],"summary":"대표 뱃지 집합 교체","description":"획득한 뱃지 중 최대 2개를 대표로 교체 지정한다(멱등). 배열 순서 = 표시 순서(rank 1·2), 빈 배열은 전부 해제. 미획득·미존재 뱃지는 7403, 중복 id 는 7400, 3개 이상은 400 이다.","operationId":"replaceFeatured","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeaturedBadgeRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFeaturedBadgeResponseDto"}}}}}}},"/api/videos":{"post":{"tags":["영상 (Video)"],"summary":"영상 메타데이터 저장 (업로드 확정)","description":"S3 업로드 완료 후 영상 메타데이터를 저장하고 좌표로 격자를 매핑한다. 해당 격자에 내 첫 영상이면 점령(occupied=true)된다.","operationId":"upload","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoUploadResponseDto"}}}}}}},"/api/videos/{videoId}/reports":{"post":{"tags":["신고 (Report)"],"summary":"영상 신고 접수","description":"다른 사람의 영상을 사유 5종(INAPPROPRIATE, PRIVACY, SPAM, COPYRIGHT, OTHER) 중 하나와 함께 신고한다. 접수된 신고는 PENDING 으로 쌓여 관리자 처리의 입력이 되며, 접수 자체는 영상 상태를 바꾸지 않는다. 같은 영상 재신고는 409, 자기 영상 신고는 400, 없는 영상·삭제·블라인드 영상은 재생 조회와 같은 404 다.","operationId":"report","parameters":[{"name":"videoId","in":"path","description":"신고할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoReportCreateResponseDto"}}}}}}},"/api/videos/presigned-url":{"post":{"tags":["영상 (Video)"],"summary":"업로드용 presigned URL 발급","description":"영상 파일을 S3에 직접 올릴 presigned URL을 발급한다. 이 URL로 PUT 업로드한 뒤 메타데이터 저장을 호출한다.","operationId":"issuePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUrlRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoPresignedUrlResponseDto"}}}}}}},"/api/videos/highlight-preview":{"post":{"tags":["영상 (Video)"],"summary":"하이라이트 선분석","description":"업로드 확정 전 원본(presign purpose=HIGHLIGHT_PREVIEW 로 올린 pending 키)의 AI 하이라이트 구간을 동기로 계산해 돌려준다. 원본 길이에 따라 응답까지 수 초에서 수십 초 걸린다(30초 1080p 기준 5초 내외). highlights 가 빈 배열이면 추천 없음이니 FE 는 추천 단계를 스킵한다. 실패 시 FE 는 직접 구간 지정으로 폴백한다 — 3502(분석 서버 문제, 재시도 가능)·3426(원본 파일 불량, 재시도 무의미)·3425(3분 초과)·3413(400, 허용 크기 초과). 결과는 저장되지 않는 임시 값이며, 같은 키로 이후 업로드 확정(POST /api/videos)이 가능하다.","operationId":"highlightPreview","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HighlightPreviewRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoHighlightPreviewResponseDto"}}}}}}},"/api/users/me/profile-image/presigned-url":{"post":{"tags":["사용자 (User)"],"summary":"프로필 이미지 업로드용 presigned URL 발급","description":"프로필 이미지를 S3 에 직접 올릴 presigned URL 을 발급한다. 이 URL 로 PUT 업로드한 뒤 받은 s3Key 로 변경 확정(PUT /api/users/me/profile-image)을 호출한다. 허용 형식은 jpg·jpeg·png·webp 이고 크기 상한은 5MB 다 — 확장자와 Content-Type 이 어긋나거나 허용 밖이면 1415, 선언 크기가 상한을 넘으면 1413.\n\n아이폰 사진(heic·heif)은 받지 않는다 — 저장해도 대부분의 브라우저가 표시하지 못하기 때문이다. 파일 선택 accept 목록에서 heic 를 빼면 iOS 가 플랫폼 수준에서 JPEG 로 변환해 주므로 정상 경로에서는 거부가 나오지 않고, 그래도 새어 들어온 원본 heic 는 1415 응답을 안내 문구로 처리한다.","operationId":"issueProfileImagePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileImagePresignRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoProfileImagePresignResponseDto"}}}}}}},"/api/routes/walk-paths":{"post":{"tags":["AI 경로 추천 (Routes)"],"summary":"세그먼트 보행 경로 조회","description":"추천 응답의 이웃 좌표쌍(출발지 구간 포함 1~8개)을 보내면 서버가 TMap 보행자 경로안내를 대신 호출해 세그먼트별 보행 좌표열과 실거리(미터)를 요청과 같은 개수, 같은 순서로 돌려준다.\n\nTMap 호출 실패·형태 위반·일 한도 소진은 에러가 아니라 200 에 해당 세그먼트 resolved: false 다 — 그 세그먼트는 직선과 직선거리 안내를 유지하면 된다 (부분 실패 허용).\n\n목록이 없거나 비었거나 9개 이상, 원소가 null, 좌표가 한국 서비스 범위(위도 33~39·경도 124~132) 밖이면 400 + developCode 14402 이고, 기능이 꺼진 환경(route.walk.enabled=false)에서는 503 + 14504 다.","operationId":"walkPaths","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteWalkPathRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRouteWalkPathResponseDto"}}}}}}},"/api/routes/recommend":{"post":{"tags":["AI 경로 추천 (Routes)"],"summary":"AI 경로 추천","description":"자연어 한 문장과 지금 보는 지도 범위를 보내면 서버 보유 후보(활성 미션·행사·장소 검색)에서 골라 방문 순서를 붙인 지점 목록(최대 8개)을 돌려준다. 지점마다 추천 이유 한 줄이 실린다.\n\n후보가 0~2개면 실패가 아니라 찾은 만큼과 notice 안내가 함께 오는 성공이다.\n\nviewport 가 뒤집혔거나 넓이 0 이거나 범위 밖이면 400 + developCode 14400, 한 변이 0.5도를 넘으면 400 + 14401 이다. 같은 사용자의 직전 시도 후 10초 안 재요청은 429 + 14429. AI 해석 실패는 502 + 14502 이고, 기능이 꺼진 환경(route.ai.enabled=false)에서는 503 + 14503 이다.","operationId":"recommend","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteRecommendRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRouteRecommendResponseDto"}}}}}}},"/api/org/event-submissions":{"post":{"tags":["행사 등재 신청 (Org Submission)"],"summary":"행사 등재 신청 제출","description":"심사 중 상태로 접수하고 신청 번호(FM-2026-XXXX 꼴)를 부여한다. 위치마다 대표 격자를 서버가 계산해 저장하며, 위치 하나의 영역은 겹침을 한 번만 세는 합집합 기준 최대 81칸이다.\n\n유형별 필수 항목이 다르다 — FESTIVAL 은 주요 프로그램, POPUP 은 운영 시간, EVENT 는 참여 방식과 참여할 승인 이벤트 회차(parentOccurrenceId)이고 자기 유형이 아닌 항목이 실려 오면 거부한다. EVENT 의 위치는 대표 위치 정확히 1곳이고, 참여할 회차가 이미 끝났으면 접수하지 않는다.\n\n위치에는 이름 필드가 없고 배열 순서가 곧 순번이다.","operationId":"submit","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventSubmissionCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionSubmitResponseDto"}}}}}}},"/api/org/event-submissions/image/presigned-url":{"post":{"tags":["행사 등재 신청 (Org Submission)"],"summary":"대표 이미지 presigned URL 발급","description":"받은 uploadUrl 로 S3 에 직접 PUT 업로드한 뒤, 응답의 s3Key 를 신청 제출·재제출 요청의 imageS3Key 로 넘긴다. jpg·jpeg·png 만 받고 상한은 10MB 다.","operationId":"issueImagePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventSubmissionImagePresignRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionImagePresignResponseDto"}}}}}}},"/api/org/email-change-request":{"post":{"tags":["행사 운영자 계정 (Org Account)"],"summary":"아이디 변경 요청","description":"아이디(공식 이메일)는 기관 인증의 근거라 자체 변경이 불가하다. 이 API 는 변경 요청을 접수만 하고, 관리자가 승인해야 실제로 바뀐다.\n\n대기 중인 요청이 있으면 그 요청이 새 값으로 갱신된다 — 마지막 요청이 유효하다.","operationId":"requestEmailChange","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgEmailChangeRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/org-account-requests":{"post":{"tags":["행사 운영자 계정 발급 요청 (Org Account Request)"],"summary":"계정 발급 요청 접수","description":"계정 발급 신청을 대기 상태로 접수한다. 관리자가 큐에서 검토해 승인하면 계정이 만들어지고 초기 비밀번호가 공식 이메일로 발송된다.\n\n같은 공식 이메일의 대기 요청이 이미 있으면 그 요청이 새 내용으로 갱신된다 — 마지막 접수가 유효하므로 더블클릭 재제출과 오타 정정 재접수가 한 건으로 수렴한다. 신청 번호는 부여하지 않는다.","operationId":"create","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgAccountRequestCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/notifications/tokens":{"post":{"tags":["알림 (Notification)"],"summary":"FCM 토큰 등록/갱신","description":"디바이스의 FCM 토큰을 현재 계정으로 등록한다(UPSERT). 같은 토큰 재등록은 충돌 없이 user_id·platform·appVersion·last_used_at 이 갱신된다 — 재로그인·계정 전환 포함. platform 이 IOS/ANDROID/WEB(대소문자 무시) 외면 10400 이다.","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushTokenRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}},"delete":{"tags":["알림 (Notification)"],"summary":"FCM 토큰 해제","description":"본인 소유(user_id 일치) 토큰 행을 삭제한다 — 멱등, 없는 토큰·소유 불일치 해제도 200. 로그아웃은 /api/auth/logout body 의 fcmToken 으로 한 번에 처리하고, 이 API 는 토큰 로테이션 등 로그아웃 외 정리 용도다.","operationId":"unregister","parameters":[{"name":"fcmToken","in":"query","description":"해제할 FCM 토큰","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/missions/{missionId}/videos":{"get":{"tags":["미션 영상 (Mission Videos)"],"summary":"미션 영상 목록 조회","description":"그 미션의 대상 격자에서 미션 기간에 촬영된 공개(PUBLIC)·READY 영상을 촬영 시각(recordedAt) 최신순으로 페이지 조회한다 — 촬영 시각이 같으면 videoId 내림차순으로 갈린다. 기간이 없는 미션(코스·지속형)은 기간 조건 없이 과거 영상까지 담고, 기간이 끝난 미션도 목록은 그대로 조회된다. 비공개·친구 공개·삭제·블라인드·인코딩 미완 영상은 본인 것이라도 제외되며, 응답은 누가 부르든 같다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 커서는 발급된 그 미션 전용이라 다른 미션 커서는 400(INVALID_CURSOR)이고, 형식이 깨진 커서도 같다. size 는 1~50 밖이면 클램프된다. 조건에 맞는 영상이 없거나 존재하지 않는 missionId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getMissionVideos","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor (opaque). 생략하면 첫 페이지","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridVideoPageResponseDto"}}}}}},"post":{"tags":["미션 영상 (Mission Videos)"],"summary":"미션 경유 영상 업로드 확정","description":"축제·팝업 미션에 영상을 올린다. 파일은 기존 presigned 발급(POST /api/videos/presigned-url)으로 S3 에 먼저 올리고 이 API 가 확정한다.\n\n좌표도 격자도 받지 않는다. 저장 위치는 서버가 그 미션의 대표 격자로 정하므로 같은 미션의 영상이 지도에서 한 칸에 모인다. 공개 범위는 PUBLIC 으로 고정되고, 업로드는 일반 업로드와 똑같이 그 격자의 점령을 만들며 뱃지·스트릭·미션 스탬프도 그대로 반영된다.\n\n같은 s3Key 로 다시 보내면 영상이 하나 더 생기지 않고 저장된 행 기준의 성공이 돌아온다. 이때 occupied 는 false, newBadges 와 completedMissions 는 빈 배열이다(첫 응답 전용 필드).\n\n촬영 시각이 미래면 400 + developCode 3424, 키 형식이 아니거나 남의 pending 키면 400 + 3401 이다. 그 밖의 모든 실패는 409 + 12409 하나로 돌아온다 — 없는 미션, 코스처럼 대상이 아닌 유형, 기간 밖, 촬영 시각이 미션 기간 밖, 대표 격자가 없는 미션, 이미 다른 자리에 쓴 키, S3 에 없는 키가 전부 여기 해당하며 사유는 갈라 주지 않는다. 이 응답을 받으면 그대로 재시도하지 말고 미션 상세를 다시 불러 업로드 가능 여부를 확인하고, 미션이 여전히 열려 있으면 presigned URL 을 새로 발급받아 파일부터 다시 올린다.\n\n인코딩이 끝나기 전에는 목록에 잡히지 않는다 — 업로드 직후 화면에 카드를 보여주려면 이 응답으로 낙관적으로 그린다(기존 업로드와 같은 성질).","operationId":"uploadMissionVideo","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MissionVideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoMissionVideoUploadResponseDto"}}}}}}},"/api/friends/requests":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청","description":"상대의 친구 코드로 요청을 보낸다. 응답 status 가 PENDING 이면 상대 수락 대기, ACCEPTED 면 상대가 먼저 보낸 요청이 있어 즉시 친구 성립(자동 수락)이다.","operationId":"request","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FriendRequestCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendRequestCreateResponseDto"}}}}}}},"/api/friends/requests/{requesterId}/reject":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청 거절","description":"받은 요청을 거절한다. 보낸 쪽에 통지는 없고, 상대는 다시 요청할 수 있다.","operationId":"reject","parameters":[{"name":"requesterId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}},"/api/friends/requests/{requesterId}/accept":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청 수락","description":"받은 요청을 수락해 친구 관계를 성립시킨다. 요청의 수신자 본인만 가능하다.","operationId":"accept","parameters":[{"name":"requesterId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}},"/api/event-videos/{videoId}/comments":{"get":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 목록 조회","description":"영상에 달린 댓글을 오래된 순으로 한 페이지 돌려준다 — 새 댓글이 아래에 쌓이는 배열이다.\n\n영상 상세가 첫 페이지(20건)를 이미 품고 있으므로 이 API 는 둘째 페이지부터를 위한 것이다. cursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 영상 목록에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.\n\n아카이브된 행사에서도 조회할 수 있고 댓글이 없으면 실패가 아니라 빈 페이지다. 비로그인으로도 조회할 수 있다.","operationId":"getComments","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentPageResponseDto"}}}}}},"post":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 작성","description":"행사 영상에 댓글을 단다. 내용은 1~500자다.\n\n이벤트가 아카이브로 넘어가면 댓글을 더 달 수 없다 — 종료 30일 후부터 409 + developCode 13422 다(기존 댓글은 계속 보인다). 그 전까지는 예정·진행 중은 물론 유예 기간(종료 후 30일)에도 쓸 수 있고, 유예 기간에 새로 올라온 영상에도 댓글을 남길 수 있다.\n\n상세에 보이는 영상에만 쓸 수 있다 — 삭제·블라인드·비공개·처리 미완료 영상과 행사 영상이 아닌 영상 id 는 올린 본인에게도 404 + 13406 이다.","operationId":"createComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoCommentRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/locations/{locationId}/videos":{"get":{"tags":["행사 (Events)"],"summary":"위치별 영상 피드 조회","description":"행사 위치에 올라온 영상을 최신 업로드순으로 한 페이지 돌려준다. 영역 안 어느 격자를 눌러 들어와도 같은 위치의 같은 피드다.\n\n여기 담기는 영상은 위치 목록의 영상 수와 정확히 같은 집합이다 — 삭제·비공개·처리 미완료 영상은 숫자에서도 목록에서도 함께 빠진다. 인코딩이 끝나기 전 영상은 아직 담기지 않는다.\n\ncursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 위치 피드에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.\n\n아카이브된 행사에서도 조회할 수 있고 영상이 없으면 실패가 아니라 빈 페이지다. 존재하지 않거나 노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다. 비로그인으로도 조회할 수 있다.","operationId":"getLocationVideos","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"locationId","in":"path","description":"행사 위치 id","required":true,"schema":{"type":"integer","format":"int64"},"example":34},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventLocationVideoPageResponseDto"}}}}}},"post":{"tags":["행사 (Events)"],"summary":"행사 영상 업로드 확정","description":"행사 위치에 영상을 올린다. 파일은 기존 presigned 발급(POST /api/videos/presigned-url)으로 S3 에 먼저 올리고 이 API 가 확정한다 — 촬영이든 갤러리 선택이든 서버 계약은 하나다.\n\n좌표를 받지 않는다. 격자는 서버가 그 위치의 대표 격자로 정하므로 현장에 없어도 올릴 수 있고, 공개 범위는 PUBLIC 으로 고정된다. 업로드는 일반 업로드와 똑같이 그 격자의 점령을 만들고 뱃지·스트릭도 그대로 반영된다(미션만 연계되지 않는다).\n\n같은 s3Key 로 다시 보내면 영상이 하나 더 생기지 않고 저장된 행 기준의 성공이 돌아온다. 이때 occupied 는 false, newBadges 는 빈 배열이다(첫 응답 전용 필드).\n\n올릴 수 있는 기간은 행사 시작부터 종료 30일 후 직전까지다. 시작 전이면 409 + developCode 13410, 마감 이후면 409 + 13409 다. 존재하지 않거나 아직 노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다.\n\n인코딩이 끝나기 전에는 피드에 잡히지 않는다 — 업로드 직후 화면에 카드를 보여주려면 이 응답으로 낙관적으로 그린다(기존 업로드와 같은 성질).","operationId":"upload_1","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"locationId","in":"path","description":"행사 위치 id","required":true,"schema":{"type":"integer","format":"int64"},"example":34}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoUploadResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/heartbeat":{"post":{"tags":["이벤트 (Event)"],"summary":"열람 heartbeat","description":"이벤트를 보는 동안 30초 주기로 보낸다. 마지막 신호가 90초 이내인 세션만 열람 인원에 센다. 비로그인은 X-Viewer-Session 헤더(공백 아님·최대 64자) 필수 — 없으면 400. 캐시 장애는 삼켜져 200 이다.","operationId":"heartbeat","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"X-Viewer-Session","in":"header","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/auth/signup":{"post":{"tags":["인증 (Auth)"],"summary":"이메일 회원가입","description":"이메일/비밀번호/닉네임으로 신규 회원을 생성한다.","operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoSignupResponseDto"}}}}}}},"/api/auth/reissue":{"post":{"tags":["인증 (Auth)"],"summary":"토큰 재발급","description":"리프레시 토큰(웹=쿠키, 앱=body)으로 새 액세스 토큰과 회전된 새 리프레시 토큰을 발급받는다. 직전 리프레시 토큰은 즉시 무효화되며, 회전된 옛 토큰 재사용 시 세션 체인이 폐기된다. 쿠키로 리프레시를 보내는 웹은 CSRF 방어를 위해 X-Client-Type 헤더가 필수다(없으면 400). body 로 보내는 앱은 생략할 수 있다.","operationId":"reissue","parameters":[{"name":"refreshToken","in":"cookie","required":false,"schema":{"type":"string"}},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app). 리프레시를 쿠키로 보내면 필수, body 로 보내면 생략 가능(생략 시 web 취급).","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReissueRequestDto"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoReissueResponseDto"}}}}}}},"/api/auth/password/reset":{"post":{"tags":["비밀번호 (Password)"],"summary":"비밀번호 재설정 확정","description":"메일 링크의 토큰으로 새 비밀번호를 설정한다. 토큰은 한 번만 쓸 수 있다.\n\n성공하면 그 계정의 모든 기기 로그인이 끊기고, 이미 발급돼 있던 액세스 토큰도 즉시 무효가 된다 — 비밀번호를 잊은 복구 흐름이라 기존 세션을 남기지 않는다.","operationId":"resetPassword","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetConfirmRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/auth/password/reset-request":{"post":{"tags":["비밀번호 (Password)"],"summary":"비밀번호 재설정 링크 요청","description":"공식 이메일로 30분 동안 유효한 재설정 링크를 보낸다. 재요청하면 이전 링크는 즉시 무효가 된다.\n\n계정이 있든 없든 항상 같은 성공 응답이다 — 이 API 로 가입 여부를 알아낼 수 없게 하기 위해서다.","operationId":"requestReset","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/auth/password/initial":{"post":{"tags":["비밀번호 (Password)"],"summary":"초기 비밀번호 설정","description":"관리자가 발급한 초기 비밀번호로 처음 로그인한 계정이 현재 비밀번호 입력 없이 새 비밀번호만으로 설정을 마친다. 성공하면 강제 변경 상태가 풀려 콘솔이 열리고, 남아 있던 재설정 링크는 폐기된다. 로그인 중인 세션은 그대로 유지된다.\n\n이미 설정을 마친 계정은 2446 으로 거절되니 비밀번호 변경(/change) 화면으로 안내하면 된다. 소셜 로그인 계정은 2445 로 거절된다.","operationId":"setInitialPassword","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordInitialRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/auth/password/change":{"post":{"tags":["비밀번호 (Password)"],"summary":"비밀번호 변경","description":"현재 비밀번호를 확인하고 새 비밀번호로 바꾼다. 성공하면 강제 변경 상태가 풀려 콘솔이 열리고, 남아 있던 재설정 링크는 폐기된다. 로그인 중인 다른 기기의 세션은 그대로 유지된다.\n\n이메일·비밀번호로 만든 계정만 쓸 수 있다 — 소셜 로그인 계정은 2445 로 거절된다.","operationId":"changePassword","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordChangeRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["인증 (Auth)"],"summary":"소셜 로그인 (OIDC)","description":"소셜 제공자의 ID Token으로 로그인/가입하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키(Set-Cookie)로 내려가 body 의 refreshToken 이 null 이고, 앱(app)은 body 로 내려간다.","operationId":"oauthLogin","parameters":[{"name":"provider","in":"path","description":"소셜 제공자","required":true,"schema":{"type":"string"},"example":"KAKAO"},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OidcLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/oauth/kakao/code":{"post":{"tags":["인증 (Auth)"],"summary":"소셜 로그인 (카카오 인가 코드)","description":"웹에서 카카오 콜백으로 받은 인가 코드로 로그인/가입한다. 서버가 REST API 키로 카카오 토큰 엔드포인트를 호출해 ID Token 을 받은 뒤, 소셜 로그인(OIDC)과 완전히 같은 검증·발급 경로를 태운다. 인가 진입점이 심은 OAUTH_NONCE 쿠키가 함께 와야 한다(없으면 401). 응답 형태는 기존 소셜 로그인과 동일하다 — 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키(Set-Cookie)로 내려가 body 의 refreshToken 이 null 이고, 앱(app)은 body 로 내려간다. 네이티브 SDK 가 교환까지 해주는 앱은 이 API 가 아니라 POST /api/auth/oauth/{provider} 를 쓴다.","operationId":"oauthCodeLogin","parameters":[{"name":"OAUTH_NONCE","in":"cookie","required":false,"schema":{"type":"string"}},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KakaoCodeLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/logout":{"post":{"tags":["인증 (Auth)"],"summary":"로그아웃","description":"Authorization 헤더의 액세스 토큰을 무효화하고 해당 디바이스(X-Device-Id)의 리프레시 세션을 삭제한다. X-Device-Id 가 없으면 해당 유저의 모든 디바이스 세션을 삭제한다. 선택 body 의 fcmToken 이 있으면 해당 FCM 푸시 토큰도 함께 정리된다 (MSG-178 logout 통합).","operationId":"logout","parameters":[{"name":"Authorization","in":"header","required":false,"schema":{"type":"string"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 모든 디바이스 세션 삭제(로그아웃-올).","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutRequestDto"}}}},"responses":{"200":{"description":"OK"}}}},"/api/auth/login":{"post":{"tags":["인증 (Auth)"],"summary":"이메일 로그인","description":"이메일/비밀번호로 로그인하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키로, 앱(app)은 body 로 내려간다.","operationId":"login","parameters":[{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/dev/social-login":{"post":{"tags":["인증-개발용 (Auth Dev)"],"summary":"[개발용] 소셜 로그인 모의","description":"실제 OIDC ID Token 검증 없이 (provider, oid)로 사용자를 find-or-create 하고 액세스+리프레시 토큰을 발급한다. 리프레시는 body 로 내려간다(앱 모드). 로컬/dev 프로파일에서만 노출.","operationId":"socialLogin","parameters":[{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevSocialLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/admin/videos/{videoId}/unblind":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"블라인드 해제","description":"BLINDED 영상을 ACTIVE 로 복구한다. 오판 복구용이며 그 신고의 RESOLVED 는 되돌리지 않는다. 없는 영상과 삭제된 영상은 404(3404), 이미 ACTIVE 면 409(3409) 다.","operationId":"unblindVideo","parameters":[{"name":"videoId","in":"path","description":"해제할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminVideoUnblindResponseDto"}}}}}}},"/api/admin/reports/{reportId}/reject":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 기각","description":"신고를 REJECTED 로 종결한다. 영상에는 아무 영향이 없고 응답의 videoStatus 는 현재 상태 그대로다. 없는 신고는 404(11404), 이미 처리된 신고는 409(11410) 다.","operationId":"reject_1","parameters":[{"name":"reportId","in":"path","description":"기각할 신고 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportProcessResponseDto"}}}}}}},"/api/admin/reports/{reportId}/approve":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 승인","description":"신고를 RESOLVED 로 종결하고 대상 영상을 블라인드한다 — 한 트랜잭션이다. 영상이 이미 BLINDED 거나 DELETED 면 영상 전이 없이 신고만 종결하며, 응답의 videoStatus 로 구분할 수 있다. 없는 신고는 404(11404), 이미 처리된 신고와 동시 처리의 늦은 쪽은 409(11410) 다.","operationId":"approve","parameters":[{"name":"reportId","in":"path","description":"승인할 신고 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportProcessResponseDto"}}}}}}},"/api/admin/organizations":{"get":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"발급된 행사 운영자 계정 목록","description":"발급 최신순으로 계정을 조회한다. 목록은 이 발급 경로가 만드는 형태(역할 ORG · 제공자 LOCAL) 만 담는다 — 재발송 대상 식별과 직접 발급 복구 확인이 목적이라서다.\n\n각 항목의 mustChange 가 화면의 사용 중 / 초기 로그인 전 라벨이다(false 가 사용 중). email 을 주면 완전 일치 검색이고, page 음수나 size 범위(1~100) 밖은 400(1425) 이다.","operationId":"getAccounts","parameters":[{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20},{"name":"email","in":"query","description":"공식 이메일 완전 일치 필터 (선택)","required":false,"schema":{"type":"string"},"example":"event@busanjin.go.kr"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminOrgAccountListResponseDto"}}}}}},"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"행사 운영자 계정 직접 발급","description":"공문으로 먼저 확인된 기관에 발급 요청 없이 계정을 만들고 초기 비밀번호를 발송한다. 결과는 승인과 같다.\n\n응답을 받지 못했으면 같은 요청을 재시도한다. 1409(이미 존재)가 오면 그 이메일로 계정이 있다는 뜻일 뿐 발급 성공의 증거가 아니므로, 계정 목록의 email 검색으로 가른다 — 결과가 있으면 발급된 것이고, 없으면 다른 계정과의 이메일 충돌이라 기관에 다른 공식 이메일을 요청한다.\n\n이미 계정이 있는 이메일은 409(1409) 다.","operationId":"issueDirect","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgAccountCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgAccountIssueResponseDto"}}}}}}},"/api/admin/organizations/{userId}/resend-password":{"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"초기 비밀번호 재발송","description":"새 초기 비밀번호를 만들어 공식 이메일로 다시 보낸다 — 재발송은 재발급이다. 평문을 저장하지 않아 보냈던 비밀번호를 다시 보낼 수 없고, 이전 초기 비밀번호는 즉시 무효가 된다.\n\n대상은 아직 초기 로그인을 마치지 않은 행사 운영자 계정뿐이다. 이미 본인이 비밀번호를 바꾼 계정은 409(1423) 이며, 그 경우의 분실 복구는 비밀번호 재설정 흐름을 안내한다.\n\n없는 사용자는 404(1404) 다.","operationId":"resendPassword","parameters":[{"name":"userId","in":"path","description":"재발송 대상 계정 id","required":true,"schema":{"type":"integer","format":"int64"},"example":42}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgAccountResendResponseDto"}}}}}}},"/api/admin/org-account-requests/{requestId}/reject":{"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"계정 발급 요청 반려","description":"요청을 반려하고 사유를 저장한다. 사유는 필수이며 메일은 발송되지 않는다 — 반려 통보는 당분간 수기이고 저장된 사유가 그 재료다.\n\n없는 요청은 404(1421), 이미 처리된 요청은 409(1422), 검토 이후 요청 내용이 바뀌었으면 409(1426) 다.","operationId":"reject_2","parameters":[{"name":"requestId","in":"path","description":"반려할 요청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgAccountRequestRejectRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/admin/org-account-requests/{requestId}/approve":{"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"계정 발급 요청 승인","description":"행사 운영자 계정을 만들고 초기 비밀번호를 공식 이메일로 발송한다. 응답에는 발송 성공 여부만 실리고 초기 비밀번호 평문은 어디에도 실리지 않는다.\n\n메일 발송이 실패해도 계정과 발급됨 상태는 유지되며 emailSent 가 false 로 온다 — 복구는 재발송 API 다. 응답 자체를 받지 못했으면 상세를 재조회해 ISSUED 인지 확인하고, 발송 확신이 없으면 재발송을 쓴다.\n\n없는 요청은 404(1421), 이미 처리된 요청과 동시 승인의 늦은 쪽은 409(1422), 검토 이후 요청 내용이 바뀌었으면 409(1426), 이미 계정이 있는 이메일은 409(1409) 다.","operationId":"approve_1","parameters":[{"name":"requestId","in":"path","description":"승인할 요청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgAccountRequestApproveRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgAccountIssueResponseDto"}}}}}}},"/api/admin/events/{submissionId}/unpublish":{"post":{"tags":["관리자 승인 행사 (Admin Approved Event)"],"summary":"행사 노출 중지","description":"승인된 행사의 지도 노출을 사유와 함께 중지한다. 중지하면 그 승인 미션이 지도 칩 목록·격자 선택·미션 상세·영상 목록·스탬프 판정·미션 경유 업로드에서 즉시 빠진다(재기동 불요). 알고 있는 missionId 로 여는 상세와 영상 목록도 없는 미션과 같은 404 가 된다.\n\n이미 완료한 사용자의 스탬프와 진행 기록은 그대로 남는다 — 중지는 노출을 끊는 것이지 기록을 회수하는 것이 아니다.\n\n사유는 신청 계정의 공식 이메일로 발송된다. 발송이 실패해도 중지는 유지되며 emailSent 가 false 로 온다 — 저장된 사유가 수기 재통지의 재료이고 재발송 API 는 없다. 중지 해제(재노출)도 이 티켓 범위 밖이다.\n\n없거나 승인되지 않은 신청은 404(13430), 이미 중지된 행사는 409(13453) 다.","operationId":"unpublish","parameters":[{"name":"submissionId","in":"path","description":"중지할 승인 행사 식별자 (= 신청 id)","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminEventUnpublishRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminEventUnpublishResponseDto"}}}}}}},"/api/admin/event-submissions/{submissionId}/reject":{"post":{"tags":["관리자 행사 등재 심사 (Admin Event Submission)"],"summary":"신청 반려","description":"신청을 반려하고 항목 코드와 사유를 이력에 남긴다. 항목 코드는 1개 이상이어야 하고 PERIOD·AREA·IMAGE·INFO 만 쓸 수 있으며 중복은 허용하지 않는다. 사유 본문도 필수다.\n\n메일은 발송되지 않는다 — 행사 운영자가 콘솔 상세에서 항목과 사유를 보고 고쳐서 다시 낸다. 승인 시 격자 겹침(13452)을 만난 경우의 다음 조작도 이 반려이고, 항목 코드는 AREA 다.\n\n없는 신청은 404(13430), 심사 중이 아니면 409(13450), 항목 코드가 비었거나 허용 밖이거나 중복이면 400(13454) 이다.","operationId":"reject_3","parameters":[{"name":"submissionId","in":"path","description":"반려할 신청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventSubmissionRejectRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/admin/event-submissions/{submissionId}/approve":{"post":{"tags":["관리자 행사 등재 심사 (Admin Event Submission)"],"summary":"신청 승인","description":"신청을 승인하고 승인 번호(APR-2026-XXXX 꼴)를 부여한다. 요청 본문이 없다 — 승인 입력은 전부 저장된 신청에서 나온다.\n\n지역축제는 지도 홈 축제 칩 미션으로, 팝업스토어는 팝업 칩 미션으로 등재되어 재기동이나 재시드 없이 기존 미션 조회 API 에 즉시 나타난다. 판정 격자는 신청한 전 위치의 셀 합집합이고 대표 격자는 서버가 그 합집합에서 다시 계산한다.\n\n전이·이력·미션 등재가 한 트랜잭션이라 절반만 반영되는 결과가 없다. 응답을 받지 못했으면 상세를 재조회해 APPROVED 인지 확인한다 — 같은 신청을 다시 승인하면 409(13450) 다.\n\n없는 신청은 404(13430), 심사 중이 아니거나 동시 승인의 늦은 쪽은 409(13450), 종료일이 이미 지난 신청은 409(13451) 다.","operationId":"approve_2","parameters":[{"name":"submissionId","in":"path","description":"승인할 신청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionApproveResponseDto"}}}}}}},"/api/admin/email-change-requests/{requestId}/reject":{"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"아이디 변경 요청 반려","description":"요청을 반려하고 사유를 저장한다. 아이디는 바뀌지 않고 메일도 발송되지 않는다 — 반려 통보는 수기이고 저장된 사유가 그 재료다. 처리 후에는 같은 계정이 다시 접수할 수 있다.\n\n검토 기준 시각을 승인과 똑같이 요구하는 것은, 검토한 내용과 다른 요청을 그 사유로 반려하는 어긋남을 막기 위해서다.\n\n없는 요청은 404(1427), 이미 처리된 요청은 409(1428), 검토 이후 내용이 바뀌었으면 409(1429) 다.","operationId":"rejectEmailChange","parameters":[{"name":"requestId","in":"path","description":"반려할 요청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailChangeRejectRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/admin/email-change-requests/{requestId}/approve":{"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"아이디 변경 요청 승인","description":"요청한 이메일로 로그인 아이디를 교체하고 새 이메일로 변경 완료를 통지한다. 요청 전이와 이메일 교체는 한 트랜잭션이라 함께 성공하거나 함께 실패한다. 비밀번호와 세션은 그대로이며 다음 로그인부터 새 아이디를 쓴다.\n\n발급·반려 통보와 달리 메일을 보내는 이유는 로그인 수단 자체가 바뀌는 사건이라서다 — 알리지 않으면 행사 운영자가 계정 접근을 잃는다. 발송이 실패해도 교체는 유지되며 emailSent 가 false 로 온다.\n\n없는 요청은 404(1427), 이미 처리된 요청은 409(1428), 검토 이후 재요청으로 내용이 바뀌었으면 409(1429), 요청한 이메일이 이미 다른 계정에 있으면 409(1409) 다.","operationId":"approveEmailChange","parameters":[{"name":"requestId","in":"path","description":"승인할 요청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailChangeApproveRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEmailChangeApproveResponseDto"}}}}}}},"/api/videos/{videoId}/visibility":{"patch":{"tags":["영상 (Video)"],"summary":"영상 공개 범위 전환","description":"본인 영상의 공개 범위를 PUBLIC·PRIVATE·FRIENDS 간 전환한다. 전환된 상태를 반환하며, 같은 값 재전환은 멱등하게 성공한다.","operationId":"setVisibility","parameters":[{"name":"videoId","in":"path","description":"공개 범위를 전환할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoVisibilityRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoVisibilityResponseDto"}}}}}}},"/api/org/profile":{"get":{"tags":["행사 운영자 계정 (Org Account)"],"summary":"계정 설정 조회","description":"계정 설정 화면의 초기값이다. 아이디(이메일)는 읽기 전용으로 함께 내려간다.","operationId":"getProfile","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgProfileResponseDto"}}}}}},"patch":{"tags":["행사 운영자 계정 (Org Account)"],"summary":"담당자 정보 수정","description":"담당자 이름과 연락처를 바꾸고 변경 후 값을 반환한다. 아이디(이메일)는 이 API 로 바꿀 수 없다.","operationId":"updateProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgProfileUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgProfileResponseDto"}}}}}}},"/api/org/event-submissions/{submissionId}":{"get":{"tags":["행사 등재 신청 (Org Submission)"],"summary":"신청 상세","description":"기본 정보와 위치 목록(순번·대표 격자·표시명 재료·제출 원본 사각형), 상태 이력, 반려 항목과 사유를 돌려준다. 반려 항목은 현재 상태가 반려일 때만 값이 있고, 과거 반려는 재제출 뒤에도 이력에 남는다.\n\n없는 신청과 남의 신청은 완전히 같은 실패 응답이다 — 응답 차이로 남의 신청 존재를 추측할 수 없다.","operationId":"getSubmission","parameters":[{"name":"submissionId","in":"path","description":"신청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionDetailResponseDto"}}}}}},"patch":{"tags":["행사 등재 신청 (Org Submission)"],"summary":"반려본 수정 재제출","description":"반려된 신청만 수정할 수 있고, 재제출하면 상태가 심사 중으로 돌아간다(신청 번호는 그대로다). 부분 수정이 아니라 전체 교체이고 등록 유형은 바꿀 수 없다 — 유형을 바꾸려면 새로 제출한다.\n\nimageS3Key 를 생략하거나 null 로 보내면 기존 대표 이미지가 유지되고, 새 pending 키를 보내면 교체된다.","operationId":"resubmit","parameters":[{"name":"submissionId","in":"path","description":"신청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventSubmissionUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionSubmitResponseDto"}}}}}}},"/api/notifications/{notificationId}/read":{"patch":{"tags":["알림 (Notification)"],"summary":"알림 하나 읽음 처리","description":"행을 탭했을 때 그 알림을 읽음으로 바꾼다 — 이미 읽은 알림을 다시 요청해도 성공이고 최초로 읽은 시각이 그대로 남는다. 없는 알림이나 남의 알림이면 10404 로, 둘을 구분하지 않는다.","operationId":"markRead","parameters":[{"name":"notificationId","in":"path","description":"읽음 처리할 알림 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":123}],"responses":{"200":{"description":"OK"}}}},"/api/notifications/read-all":{"patch":{"tags":["알림 (Notification)"],"summary":"알림 모두 읽음 처리","description":"안읽은 알림을 전부 읽음으로 바꾼다 — 안읽은 알림이 하나도 없어도 성공한다.","operationId":"markAllRead","responses":{"200":{"description":"OK"}}}},"/api/notifications/preferences/{category}":{"patch":{"tags":["알림 (Notification)"],"summary":"카테고리 수신 토글","description":"카테고리 하나의 수신 여부를 바꾸고 변경 후 전체 상태를 반환한다 — 같은 값 재전환은 멱등. category 가 8종(BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT, 대소문자 무시) 외면 10420 이다. off 는 발송만 막고 off 중 쌓인 알림이 on 복귀 후 재발송되는 일은 없다. MISSION_NEARBY 는 서버 발송이 없어 기기가 발화 전 이 설정을 조회해 로컬로 억제한다.","operationId":"update","parameters":[{"name":"category","in":"path","description":"알림 카테고리 — BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY (대소문자 무시)","required":true,"schema":{"type":"string"},"example":"HOTZONE"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPreferenceUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPreferenceResponseDto"}}}}}}},"/api/event-videos/{videoId}/comments/{commentId}":{"delete":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 삭제","description":"댓글을 실제로 지운다(복구 없음). 본인 댓글만 지울 수 있고 남의 댓글이면 403 + developCode 13403 이다.\n\n이미 지운 댓글을 다시 지우면 404 + 13407 이다 — 없는 댓글의 삭제를 성공으로 돌려주면 화면 상태 불일치가 감춰지기 때문이다(도움돼요 취소는 토글이라 멱등인 것과 다르다).\n\n아카이브된 행사(종료 30일 후)에서는 409 + 13422 다.","operationId":"deleteComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"commentId","in":"path","description":"댓글 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3021}],"responses":{"200":{"description":"OK"}}},"patch":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 수정","description":"댓글 내용을 통째로 바꾼다. 본인 댓글만 고칠 수 있고 남의 댓글이면 403 + developCode 13403, 없거나 다른 영상의 댓글이면 404 + 13407 이다.\n\n작성 시각은 그대로다(수정 이력을 남기지 않는다). 아카이브된 행사에서는 자기 댓글이든 남의 댓글이든 409 + 13422 로 같다 — 잠금이 권한 판정보다 앞이다.","operationId":"updateComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"commentId","in":"path","description":"댓글 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3021}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoCommentRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentResponseDto"}}}}}}},"/api/zones":{"get":{"tags":["구역 (Zone)"],"summary":"구역 목록 조회","description":"전체 구역(zone) 목록을 반환한다. 검색바에서 구역을 골라 지도를 옮기거나 구역 범위를 오버레이로 그릴 때 쓴다 — 표시명은 격자 응답의 zoneName·zoneCell 을 그대로 조립하면 되므로 이 목록으로 이름을 계산할 필요가 없다. 시딩 전이면 빈 배열(전 시스템이 행정동 폴백으로 동작).","operationId":"getZones","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListZoneResponseDto"}}}}}}},"/api/users/me":{"get":{"tags":["사용자 (User)"],"summary":"내 프로필 조회","description":"소셜 로그인이 자동 저장한 이메일·닉네임을 반환한다. 항상 본인 계정만 — 경로에 대상 식별자가 없다.","operationId":"getMe","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}},"delete":{"tags":["사용자 (User)"],"summary":"계정 삭제","description":"내 계정을 즉시·비가역 삭제한다. 연쇄 개인 데이터·영상 S3 객체가 제거되고 전 디바이스 세션이 무효화된다. 같은 이메일·카카오 계정으로 다시 로그인하면 신규 가입이다.","operationId":"deleteMe","parameters":[{"name":"Authorization","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/search/trending":{"get":{"tags":["인기 검색어 (Trending)"],"summary":"인기 검색어 TOP 10","description":"오늘+어제(KST) 검색어 집계를 합산해 상위 10개를 순위·검색어로 반환한다. 동률은 검색어 사전순. 검색 횟수와 장소 정보는 포함하지 않으며(클릭 후 장소 검색 API 재호출), 집계가 없으면 200 + 빈 배열이다.","operationId":"getTrendingKeywords","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListTrendingKeywordResponseDto"}}}}}}},"/api/search/places":{"get":{"tags":["장소 검색 (Search)"],"summary":"장소 검색 (장소명 → 좌표·격자)","description":"카카오 로컬 키워드 검색 결과(정확도순 ≤15건)에 각 좌표의 격자 ID 를 얹어 반환한다. 선택 즉시 lat/lng 지도 이동 + gridId 격자 하이라이트. q 누락 400 / trim 후 빈 q·무매치 200 [] / 카카오 장애·타임아웃 502(developCode 5502). 비로그인도 호출할 수 있고 결과는 로그인 때와 같다 — 비로그인 호출은 X-Viewer-Session 헤더(공백 아님·최대 64자·콜론 불가)를 실으면 인기 검색어 집계에 잡히고, 안 실어도 검색은 정상 200 이다.\n\nlat·lng 에 지금 보고 있는 지도의 중심 좌표를 실으면 그 중심 반경 20km 안의 장소를 먼저 찾는다. 근처에 결과가 하나도 없으면 위치 없이 다시 찾아 전국 결과를 주므로 좌표를 붙였다는 이유로 결과가 사라지지는 않는다. 두 값은 반드시 한 쌍으로 보내야 하고, 한쪽만 오거나 숫자가 아니거나 대한민국 범위(위도 33~39·경도 124~132) 밖이면 400 + developCode 5400 이다. 좌표를 아예 안 보내면 종전과 똑같이 동작한다.","operationId":"searchPlaces","parameters":[{"name":"q","in":"query","description":"검색어 (자유 텍스트 장소명)","required":true,"schema":{"type":"string"},"example":"부산대"},{"name":"lat","in":"query","description":"지도 중심 위도 (33.0~39.0). lng 과 한 쌍으로만 유효하다","required":false,"schema":{"type":"number","format":"double"},"example":35.1578},{"name":"lng","in":"query","description":"지도 중심 경도 (124.0~132.0). lat 과 한 쌍으로만 유효하다","required":false,"schema":{"type":"number","format":"double"},"example":129.0594},{"name":"X-Viewer-Session","in":"header","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListPlaceSearchResponseDto"}}}}}}},"/api/regions/{regionCode}/grids":{"get":{"tags":["전역 탐색 (Region Explore)"],"summary":"행정동 격자 카드 리스트 + 헤더 카운트 조회","description":"그 행정동 격자들 중 전역 공개 콘텐츠(공개·인코딩 완료·타인 영상 포함)가 있는 격자를 카드로 반환한다. 카운트(gridCount·videoCount)는 limit 무관 전체 기준이라 지도 홈 패널(sort=LATEST&limit=20, SRS FR-MAP-10)과 전체 보기(limit 생략)가 같은 값을 받지만, **전역 공개 콘텐츠를 센 값이라 패널 헤더(\"이 지역 격자 N개 · 영상 M개\")에 쓰면 안 된다** — 헤더는 내 도감 집계 응답의 currentRegion(중심 동 전체의 내 것, MSG-374)이 채운다. 카드 커버는 격자 대표(cover)와 같은 영상이고 썸네일은 presigned GET URL 이다. 미존재·무콘텐츠 regionCode 는 404 가 아니라 200 + 카운트 0·빈 배열이다.","operationId":"getRegionGrids","parameters":[{"name":"regionCode","in":"path","description":"행정동 코드 — reverse-geocode·전체 지역 리스트의 regionCode 를 그대로 전달","required":true,"schema":{"type":"string"},"example":2644056000},{"name":"sort","in":"query","description":"정렬 — POPULAR(조회수 합)·LATEST(최신 공개 영상). 대문자 전용이며 소문자 포함 무효 값은 400 이다. 지도 홈 패널은 LATEST (SRS FR-MAP-10, 생략 기본값은 POPULAR 유지)","required":false,"schema":{"type":"string","default":"POPULAR","enum":["POPULAR","LATEST"]},"example":"LATEST"},{"name":"limit","in":"query","description":"카드 수 상한 — 지도 홈 패널은 20 (SRS FR-MAP-10). 생략하면 전부, 1 미만은 1 로 보정한다","required":false,"schema":{"type":"integer","format":"int32"},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionExploreResponseDto"}}}}}}},"/api/regions/stats":{"get":{"tags":["행정동 (Region)"],"summary":"내 행정동별 수집률 조회","description":"로그인 사용자가 점령(수집)한 격자를 행정동별로 집계한 수집률 리스트를 반환한다. parentCode 로 시군구를 좁힐 수 있고(실존하지 않는 코드면 404/6404), collectedOnly=false 면 롤백으로 0이 된 행정동도 포함한다. 수집이 없으면 404 가 아니라 200 + 빈 배열.","operationId":"getStats","parameters":[{"name":"parentCode","in":"query","description":"상위 시군구 코드. 생략하면 전국. 실존하지 않으면 6404","required":false,"schema":{"type":"string"},"example":11680},{"name":"collectedOnly","in":"query","description":"true=수집한 행정동만, false=손댄 행정동 전부(롤백 0-row 포함)","required":false,"schema":{"type":"boolean","default":true},"example":true}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionStatResponseDto"}}}}}}},"/api/regions/stats/national":{"get":{"tags":["행정동 (Region)"],"summary":"내 전국 탐험률 재료 (분자·분모)","description":"도감·프로필 헤더의 \"전체 지도 N% 탐험\" 재료. 내가 점령한 격자 수(전국 합)와 전국 격자 총수를 반올림 없는 원값 정수 2개로 반환한다. 비율·표시 자릿수·100 상한은 화면이 min(100, 분자/분모 × 100) 으로 계산한다. 수집이 없어도 오류가 아니라 분자 0. 분모가 0 이면 기준 데이터 미적재 상태라 화면은 비율을 그리지 않는다.","operationId":"getNationalStat","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionNationalStatResponseDto"}}}}}}},"/api/regions/stats/by-point":{"get":{"tags":["행정동 (Region)"],"summary":"현재 위치 행정동 탐험률 (좌표 → 수집률)","description":"도감 갤러리 진입 초기값. 현재 위치 좌표가 속한 행정동 1건의 내 수집률을 반환한다. 그 행정동에 수집이 없어도 0% 로 합성해 반환하고, 어떤 행정동에도 안 속하면(바다·국외) 404 가 아니라 200 + data null. 서비스 범위 밖 좌표는 400(6400).","operationId":"getStatByPoint","parameters":[{"name":"lat","in":"query","description":"위도","required":true,"schema":{"type":"number","format":"double"},"example":37.4979},{"name":"lng","in":"query","description":"경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0276}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionStatResponseDto"}}}}}}},"/api/regions/stats/by-grid":{"get":{"tags":["행정동 (Region)"],"summary":"격자 중심 행정동 탐험률 (격자 클릭 → 수집률)","description":"클릭한 격자의 중심점이 속한 행정동 1건의 내 수집률을 반환한다. 귀속 축이 수집률 집계(MSG-155)와 같아 탐험률·라벨이 일치한다. 중심점이 어떤 행정동에도 안 속하거나 gridId 형식이 이상하면 200 + data null(별도 에러 코드 없음).","operationId":"getStatByGrid","parameters":[{"name":"gridId","in":"query","description":"격자 ID \"{grid_y}_{grid_x}\"","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionStatResponseDto"}}}}}}},"/api/regions/reverse-geocode":{"get":{"tags":["행정동 (Region)"],"summary":"역지오코딩 (좌표 → 행정동)","description":"좌표를 포함하는 행정동 1건을 반환한다. 포함 행정동이 없으면(바다·국외) 404가 아니라 200 + data null. 서비스 좌표 범위(한국) 밖이면 400(6400).","operationId":"reverseGeocode","parameters":[{"name":"lat","in":"query","description":"위도","required":true,"schema":{"type":"number","format":"double"},"example":37.4979},{"name":"lng","in":"query","description":"경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0276}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionResponseDto"}}}}}}},"/api/regions/explore":{"get":{"tags":["전역 탐색 (Region Explore)"],"summary":"전체 지역 리스트 조회","description":"전역 공개 콘텐츠가 있는 행정동을 20개씩 반환한다. 로그인 사용자가 직접 최근 업로드한 지역이 먼저 나오고 나머지는 격자 수 내림차순이다. hasNext가 true면 nextCursor를 다음 요청의 cursor에 그대로 전달한다.","operationId":"getExploreRegions","parameters":[{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionExplorePageResponseDto"}}}}}}},"/api/regions/districts":{"get":{"tags":["행정동 (Region)"],"summary":"시군구 목록 (검색 지역 필터)","description":"검색 화면 \"전체 지역\" 목록용 시군구 전량. 이름·식별자와 그 구의 전체 격자 수를 준다. 격자 수는 사용자 무관 값이고 0 인 시군구는 빠진다. 정렬은 이름순, 같은 이름은 식별자순. 응답의 parentCode 는 /api/regions/stats 의 parentCode 로 그대로 이어 쓸 수 있다.","operationId":"getDistricts","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionDistrictResponseDto"}}}}}}},"/api/org/events":{"get":{"tags":["행사 운영자 콘솔 (Org)"],"summary":"승인 이벤트 목록 조회","description":"참여 신청 모달의 재료 — 시·도 칩, 시·도별 건수, 이벤트 목록이다. 담기는 것은 아직 끝나지 않은 회차(예정·진행 중)뿐이고, 종료된 행사(업로드 유예·아카이브)는 참여를 신청해도 열 자리가 없으므로 빠진다. 일반 사용자 조회와 달리 노출 시작 전인 예정 회차도 담긴다 — 심사에 시간이 걸려 행사 운영자는 미리 부모 이벤트를 골라야 한다.\n\ntotalCount 와 cityCounts 는 city·name 을 적용하지 않은 전체 기준이라 검색 중에도 칩 건수가 고정이고, events 에만 두 파라미터가 적용된다. cityCounts 는 건수 내림차순·동수는 이름 오름차순, events 는 시작일 오름차순·동시각은 회차 id 오름차순이다.\n\nplaceLabel 은 그 회차의 위치 중 표시 순서가 가장 앞선 것의 이름이고, 위치가 없으면 null 이다. 존재하지 않는 시·도 값은 실패가 아니라 빈 목록이다.","operationId":"getApprovedEvents","parameters":[{"name":"city","in":"query","description":"시·도 필터 — cityCounts 의 cityName 저장값과 정확 일치","required":false,"schema":{"type":"string"},"example":"부산"},{"name":"name","in":"query","description":"이벤트 이름 검색 — 부분 일치, 대소문자 무시","required":false,"schema":{"type":"string"},"example":"영화제"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgEventListResponseDto"}}}}}}},"/api/org/event-submissions/my":{"get":{"tags":["행사 등재 신청 (Org Submission)"],"summary":"내 신청 목록","description":"콘솔 홈 현황 카드와 최근 신청 목록의 재료다. 상태별 건수는 내 신청 전체 기준이고 목록은 최신 제출 순이다. 페이지네이션은 없다.","operationId":"getMySubmissions","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionMyListResponseDto"}}}}}}},"/api/notifications":{"get":{"tags":["알림 (Notification)"],"summary":"알림 목록 조회","description":"받은 알림을 최신순으로 한 페이지 반환한다. 최근 30일 이내 생성분만 보이고, 알림 설정을 꺼서 발송되지 않은 알림은 빠진다 — 전송률 상한이나 푸시 토큰 없음으로 발송되지 않은 알림은 보인다. 다음 페이지는 응답의 nextCursor 를 cursor 로 다시 넘긴다. 목록 조회는 읽음 상태를 바꾸지 않는다.","operationId":"getInbox","parameters":[{"name":"cursor","in":"query","description":"직전 응답의 nextCursor — 생략하면 첫 페이지","required":false,"schema":{"type":"integer","format":"int64"},"example":123},{"name":"size","in":"query","description":"페이지 크기 — 0 이하면 20, 50 초과면 50 으로 자른다","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPageResponseDto"}}}}}}},"/api/notifications/unread-count":{"get":{"tags":["알림 (Notification)"],"summary":"안읽은 알림 개수 조회","description":"목록과 같은 노출 조건으로 안읽은 알림 수를 센다 — 없으면 0 이다.","operationId":"getUnreadCount","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationUnreadCountResponseDto"}}}}}}},"/api/notifications/preferences":{"get":{"tags":["알림 (Notification)"],"summary":"알림 설정 조회","description":"카테고리 8종(BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT) 전부의 수신 상태를 반환한다. 설정을 만진 적 없는 사용자는 전부 true 다 — opt-out 기본 전부 on. MODERATION 은 설정 대상이 아니라 목록에 없다 (수신 거부 불가).","operationId":"getPreferences","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPreferenceResponseDto"}}}}}}},"/api/missions/{missionId}":{"get":{"tags":["미션 (Missions)"],"summary":"미션 상세 조회","description":"미션 ID 하나의 상세 — 미션 정보와 렌더 shape(목록과 같은 필드), 내 진행도와 스탬프 보유 여부, 이 미션에 올라온 전체 영상 개수, 코스라면 포토스팟별 방문 여부·영상 개수를 한 번에 반환한다. spotStats 는 shape.spots 와 같은 순서로 오고, 코스가 아니면 null 대신 빈 배열이다.\n\n기간 판정은 하지 않는다 — 기간이 끝난 미션도 행이 남아 있으면 조회되고, 영상 개수는 그 미션이 활성일 때 촬영된 것만 센다(미션 영상 목록 GET /api/missions/{missionId}/videos 의 실제 후보 수와 항상 같다). 존재하지 않는 미션 ID 는 404 + developCode 12404(MISSION_NOT_FOUND)다.\n\n비로그인으로도 조회된다(MSG-454). 이때 사용자별 값은 빠진다 — progress 는 키는 있고 값이 null 이며 spotStats[].visited 는 전부 false 다. 미션 정보·전체 영상 수·스팟별 영상 수는 로그인과 같다.","operationId":"getMissionDetail","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":412}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoMissionDetailResponseDto"}}}}}}},"/api/missions/progress":{"get":{"tags":["미션 (Missions)"],"summary":"미션별 내 진행도 조회","description":"미션 id 여러 개의 내 진행도(채운 칸/목표 칸)와 스탬프 보유 여부를 한 번에 반환한다. 채운 칸은 스탬프 판정과 같은 술어로 센다 — 미션 기간 안에 촬영한 내 영상(삭제 제외)이 있는 격자 수다. 영상을 전부 지우면 진행도는 0으로 돌아가지만 스탬프는 비회수라 completed 는 남는다 — \"0/1 인데 완료\"가 정상 응답이다.\n\nmissionIds 가 없거나 비면 빈 배열이고(오류 아님), 존재하지 않는 id 는 응답에서 빠진다. 기간이 끝난 미션도 조회된다. 배열 순서는 missionId 오름차순으로 고정된다(요청 순서 미보존). 300개 초과는 400 + developCode 12403 으로 거절한다.","operationId":"getMyProgress","parameters":[{"name":"missionIds","in":"query","description":"미션 id 목록 — 콤마 구분 또는 반복 파라미터. 없거나 비면 빈 배열 응답, 300개 초과는 거절","required":false,"schema":{"type":"array","items":{"type":"integer","format":"int64"}},"example":"412,413"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionProgressResponseDto"}}}}}}},"/api/missions/aggregation":{"get":{"tags":["미션 (Missions)"],"summary":"넓은 축척용 미션 행정 단위 집계 조회 (줌아웃)","description":"지도를 축소해 개별 핀을 그릴 수 없는 축척에서, bbox 안의 축제·팝업 미션을 행정 단위(동·구·시)로 묶어 지역 이름과 개수로 반환한다. 단위 전환 시점은 서버가 정하지 않으며 클라이언트가 화면 축척에 맞춰 unit 만 바꿔 부른다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 미션 수, 그 묶음의 미션 id 목록이 온다. 대표 좌표는 묶음에 속한 미션 귀속점의 평균이라 마커가 실제 데이터 위에 선다. missionIds 는 묶음 마커를 눌러 줌인한 뒤 개별 조회(GET /api/missions/active) 결과와 교집합을 내 목록을 좁히는 재료다 — 카드 재료는 개별 조회 응답에 있다.\n\n미션이 속한 격자 사각형이 아니라 그 사각형 중앙의 귀속점이 bbox 안인지로 센다. 사각형이 화면에 걸쳤지만 중심이 밖인 미션은 빠지며, 이 때문에 개별 조회와 집계를 갈아타는 순간 마커 수가 미세하게 달라질 수 있다. 행정동이 판정되지 않은 미션은 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 마지막에 온다. 범위 안에 미션이 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용, 정확히 상한값은 허용). 초과 시 400 + developCode 12401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히면 12400, type 이 없거나 EVENT·POPUP 이 아니면 12402, unit 이 없거나 미지원 값이면 12405 다. 응답에 사용자별 값은 없다.","operationId":"getMissionAggregates","parameters":[{"name":"type","in":"query","description":"미션 종류 — EVENT(지역축제), POPUP(팝업스토어). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"POPUP"},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"SIGUNGU"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionRegionAggregateResponseDto"}}}}}}},"/api/missions/active":{"get":{"tags":["미션 (Missions)"],"summary":"뷰포트 내 활성 미션 목록 조회","description":"지도 화면 bbox(남서~북동 좌표) 안의, 고른 종류(type)의 활성 미션을 유형별 렌더 shape(코스=PATH·축제/팝업=BOX)로 반환한다. bbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 잘라서 응답하지 않고 400 + developCode 12401(VIEWPORT_TOO_LARGE)로 거절한다. 클라이언트는 격자 개별 조회(GET /api/grids)를 멈추는 것과 같은 0.5도 지점에서 이 조회도 멈추고 확대 안내를 그린다.\n\n보이는 범위에 그 종류 미션이 없으면 실패가 아니라 빈 배열이다(뷰포트가 너무 넓은 12401 과 다른 상태). 한국 밖이지만 WGS84 정의역 안인 bbox 도 오류가 아니라 빈 배열이다. 응답에 사용자별 값은 없다 — 진행도는 GET /api/missions/progress 로 따로 받는다.","operationId":"getActiveMissionsInViewport","parameters":[{"name":"type","in":"query","description":"미션 종류 — EVENT(지역축제), POPUP(팝업스토어), COURSE(경로추천). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"POPUP"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionResponseDto"}}}}}}},"/api/hotzones":{"get":{"tags":["핫구역 (HotZone)"],"summary":"뷰포트 내 핫구역 조회","description":"지도 화면 bbox(남서~북동 좌표) 안의 핫구역을 핫스코어 내림차순으로 반환한다. 전국 상위 K(50)·최소 임계(3) 판정 후 뷰포트 필터 — 없으면 빈 목록이다.\n\n항목마다 표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). 이름 때문에 마커마다 단건 조회를 돌릴 필요가 없다.","operationId":"getHotZones","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoHotZoneListResponseDto"}}}}}}},"/api/hotzones/aggregation":{"get":{"tags":["핫구역 (HotZone)"],"summary":"뷰포트 내 핫구역 행정 단위 집계 조회","description":"축소 화면용 — 뷰포트 안 핫구역을 행정 단위(동·구·시)로 묶어 지역 이름과 핫 격자 수로 반환한다. 묶음 대상은 개별 조회(GET /api/hotzones)와 완전히 같은 판정 집합이라 두 화면을 갈아타도 세는 대상이 달라지지 않는다.\n\n항목마다 gridIds 가 함께 온다 — 묶음 마커를 눌러 줌인한 뒤 개별 조회 결과와 교집합으로 목록을 좁히는 재료다. count 는 핫 격자 수이고 핫스코어 합산이 아니다. 행정동이 판정되지 않은 격자는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 마지막에 온다. 범위 안에 핫 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용, 정확히 상한값은 허용). 초과 시 400 + developCode 8401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 누락·뒤집히면 8400, unit 이 없거나 미지원 값이면 8405 다. 응답에 사용자별 값은 없다.","operationId":"getHotZoneAggregates","parameters":[{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"SIGUNGU"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListHotZoneRegionAggregateResponseDto"}}}}}}},"/api/grids":{"get":{"tags":["격자 (Grid)"],"summary":"뷰포트 내 색칠 격자 조회 (커서 페이지네이션)","description":"지도 화면 bbox(남서~북동 좌표) 안에서 내가 점령한 격자를 (grid_y, grid_x) 오름차순으로 반환한다. 응답의 nextCursor를 다음 요청 cursor에 넣어 이어서 조회한다. bbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 잘라서 응답하지 않고 400 + developCode 4402(VIEWPORT_TOO_LARGE)로 거절한다.\n\n항목마다 표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). 이름 때문에 다른 API를 더 호출할 필요가 없다.","operationId":"getOccupiedInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05},{"name":"cursor","in":"query","description":"다음 페이지 커서 (직전 응답의 nextCursor). 첫 페이지는 생략","required":false,"schema":{"type":"string"},"example":"MTk0MjJfOTU4Mg=="},{"name":"size","in":"query","description":"페이지 크기 (기본 1000, 최대 5000)","required":false,"schema":{"type":"integer","format":"int32","default":1000},"example":1000}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOccupiedGridPageResponseDto"}}}}}}},"/api/grids/{gridId}":{"get":{"tags":["격자 (Grid)"],"summary":"단일 격자 색칠 상태 조회","description":"특정 격자를 내가 점령(색칠)했는지와 내 영상 수를 반환한다. 미점령 격자도 404가 아니라 occupied=false로 응답한다.\n\n표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). regionName은 아직 아무도 영상을 올리지 않은 격자에도 실리고, 어느 행정동에도 속하지 않거나 서비스 범위(한국) 밖인 격자면 null이다(에러가 아니다).","operationId":"getCell","parameters":[{"name":"gridId","in":"path","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridCellResponseDto"}}}}}}},"/api/grids/{gridId}/videos":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 영상 목록 조회","description":"그 격자에 쌓인 공개(PUBLIC)·READY 영상을 전역(본인·타인 포함)에서 조회수(viewCount) → 최신(createdAt) 순으로 페이지 조회한다. 비공개·삭제·인코딩 미완 영상은 본인 것이라도 제외한다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 무효 커서는 400(INVALID_CURSOR)이고, size 는 1~50 밖이면 클램프된다. 후보가 없거나 존재하지 않는 gridId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getGridGlobalVideos","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor (opaque). 생략하면 첫 페이지","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridVideoPageResponseDto"}}}}}}},"/api/grids/{gridId}/my-videos":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자별 내 영상 리스트 조회","description":"로그인 사용자가 해당 격자에 올린 본인 영상을 최근 업로드 순(createdAt DESC)으로 반환한다. 미점령·타인만 점령한 격자·존재하지 않는 gridId 는 빈 배열이다. 썸네일은 presigned GET URL 로 내려주며 READY 이전이면 null 이다.","operationId":"getGridVideos","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridVideoResponseDto"}}}}}}},"/api/grids/{gridId}/missions":{"get":{"tags":["미션 (Missions)"],"summary":"격자가 대표 격자인 미션 조회","description":"지도에서 누른 격자가 어느 축제·팝업 미션의 자리인지 되짚는다. 미션 경유로 올린 영상은 그 미션의 대표 격자 한 칸에만 저장되므로, 영상이 모인 칸을 눌러 무슨 미션이었는지 확인하는 경로다.\n\n기간 필터가 없다 — 끝난 축제도 담긴다. 진행 중인지 시작 전인지 끝났는지는 startAt·endAt 을 서버 시각과 견주어 화면이 판정한다. 배열 첫 항목이 화면 진입 기본값이 되도록 진행 중 → 시작 전(임박한 순) → 종료(최근 종료 순)로 정렬한다.\n\n판정 범위(축제 9×9)에만 걸친 격자는 나오지 않는다 — 나오는 것은 영상이 모인 자리로 지목된 미션뿐이다. 어떤 미션의 대표 격자도 아닌 격자와 격자 형식이 아닌 문자열은 오류가 아니라 빈 배열이다. videoCount 는 미션 상세의 videoCount 와 같은 술어라 두 화면의 숫자가 어긋나지 않는다. 비로그인으로도 조회할 수 있다.","operationId":"getMissionsByGrid","parameters":[{"name":"gridId","in":"path","description":"격자 id — \"{gridY}_{gridX}\" 포맷","required":true,"schema":{"type":"string"},"example":"19443_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridMissionResponseDto"}}}}}}},"/api/grids/{gridId}/hourly-uploads":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 시간대 분포 조회","description":"그 격자의 공개(PUBLIC)·READY 영상이 업로드된 시간대 분포를 KST 0시부터 23시까지 24구간 개수로 반환한다. 세는 대상은 전역 영상 목록(/videos)과 같아 카드에 보이는 영상만 세어진다 — 비공개·삭제·인코딩 미완 영상은 본인 것이라도 빠진다. 집계 구간은 전체 누적이며, 응답의 hours 는 항상 24개·hour 오름차순이라 빈 시간대도 count 0 으로 실린다. 공개 영상이 없는 격자·존재하지 않는 gridId 도 전 구간 0 인 정상 응답이다.","operationId":"getGridHourlyUploads","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridHourlyUploadResponseDto"}}}}}}},"/api/grids/{gridId}/event-locations":{"get":{"tags":["행사 (Events)"],"summary":"격자가 속한 행사 위치 조회","description":"지도에서 누른 격자가 어느 행사 위치에 속하는지 해석한다. 영상은 위치의 대표 격자 하나에만 저장되므로, 영역 안 아무 격자나 눌러도 같은 위치가 나오는 이 역조회가 위치별 영상 피드로 들어가는 유일한 경로다.\n\n같은 장소에서 행사가 여러 번 열렸으면 회차마다 한 항목씩 배열로 온다. 배열 첫 항목이 화면 진입 기본값이 되도록 진행 중 → 예정 → 업로드 유예 → 아카이브 순으로 정렬하며, 예정끼리는 임박한 순, 나머지는 최근 순이다. 아직 노출 기간 전인 예정 회차는 배열에 담기지 않는다.\n\n어떤 행사 위치에도 속하지 않는 격자와 격자 형식이 아닌 문자열은 오류가 아니라 빈 배열이다. 비로그인으로도 조회할 수 있다.","operationId":"getEventLocationsByGrid","parameters":[{"name":"gridId","in":"path","description":"격자 id — \"{gridY}_{gridX}\" 포맷","required":true,"schema":{"type":"string"},"example":"19443_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridEventLocationResponseDto"}}}}}}},"/api/grids/{gridId}/cover":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 대표 영상 조회","description":"그 격자를 전역에서 대표하는 영상 1건을 반환한다. 공개(PUBLIC)·READY 영상 중 조회수(view_count) → 최신(createdAt) 순으로 뽑으며, 본인·타인 영상 모두 후보다. 비공개·삭제·인코딩 미완 영상은 제외한다. 후보가 없으면(미점령·비공개만·존재하지 않는 gridId) data 는 null 이다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getGridCover","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridCoverVideoResponseDto"}}}}}}},"/api/grids/aggregation":{"get":{"tags":["격자 (Grid)"],"summary":"뷰포트 내 색칠 격자 행정 단위 집계 조회 (줌아웃)","description":"응답 data는 {currentRegion, items} 객체다. currentRegion은 뷰포트 중심이 속한 행정동의 이름과 그 동 전체에서 내가 점령한 격자 수·영상 수를 담는다. 화면 범위나 unit과 무관하며, 중심이 해상 또는 서비스 범위 밖일 때만 null이다.\n\nitems는 bbox 안에서 내가 점령한 격자를 행정 단위로 묶어 센 목록이다. 단위 전환 시점은 서버가 정하지 않으며 클라이언트가 화면 축척에 맞춰 unit만 바꿔 부른다. items가 비어 있어도 한국 내 중심점의 currentRegion은 이름과 0 집계를 독립적으로 담는다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 격자 수가 온다. 대표 좌표는 그 묶음에 속한 점령 격자 중심의 평균이라 마커가 실제 데이터 위에 선다. 어느 단위로 묶어도, 항목을 더 묶어 합산해도 같은 bbox 개별 격자 조회의 총 개수와 일치한다.\n\n행정동이 판정되지 않은 격자(해상 등)는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 온다. 점령 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용). 초과 시 400 + developCode 4402, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히면 4401, unit 이 없거나 미지원 값이면 4405 다.","operationId":"getOccupiedAggregatesInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"DONG"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridAggregationResponseDto"}}}}}}},"/api/friends":{"get":{"tags":["친구 (Friend)"],"summary":"친구 목록 조회","description":"수락된 친구 전체를 반환한다 — 누가 먼저 요청했는지와 무관하다. 기본 정렬은 친구가 된 시각 내림차순이고 sort=nickname 이면 닉네임순이다. 친구가 없으면 빈 배열.","operationId":"getFriends","parameters":[{"name":"sort","in":"query","description":"정렬 기준 — recent(기본, 친구가 된 시각 내림차순) 또는 nickname","required":false,"schema":{"type":"string"},"example":"nickname"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFriendListItemResponseDto"}}}}}}},"/api/friends/{userId}/profile":{"get":{"tags":["친구 (Friend)"],"summary":"친구 프로필·도감 요약 조회","description":"친구의 프로필(닉네임·프로필 이미지·도감 색상)과 도감 요약(수집 격자 수·영상 총합·방문 동 수), 최근 수집 격자 최대 30개를 한 번에 반환한다. 도감 요약 수치는 그 친구가 자기 도감에서 보는 값과 같다. 썸네일은 그 격자에 재생 가능한 공개 영상이 있을 때만 붙는다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendProfile","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendProfileResponseDto"}}}}}}},"/api/friends/{userId}/grids":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 뷰포트 조회","description":"지도 화면 bbox(남서~북동 좌표) 안에서 그 친구가 점령한 격자를 (grid_y, grid_x) 오름차순으로 반환한다. 응답 형상·검증 규칙·에러는 내 격자 조회(GET /api/grids)와 같다 — 응답의 nextCursor 를 다음 요청 cursor 에 넣어 이어 조회하고, bbox span 상한 0.5도는 위도·경도 각 변에 따로 적용되며 초과 시 400 + 4402(VIEWPORT_TOO_LARGE)로 거절된다. 격자 색상은 내려주지 않는다(FE 단일색 렌더). 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGrids","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05},{"name":"cursor","in":"query","description":"다음 페이지 커서 (직전 응답의 nextCursor). 첫 페이지는 생략","required":false,"schema":{"type":"string"},"example":"MTk0MjJfOTU4Mg=="},{"name":"size","in":"query","description":"페이지 크기 (기본 1000, 최대 5000)","required":false,"schema":{"type":"integer","format":"int32","default":1000},"example":1000}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOccupiedGridPageResponseDto"}}}}}}},"/api/friends/{userId}/grids/{gridId}/videos":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 영상 목록 조회","description":"그 친구가 해당 격자에 올린 영상을 최근 업로드 순으로 반환한다. 친구에게 공개된 영상(전체 공개·친구만 보기)만 담기고 비공개 영상은 포함되지 않으며, 삭제·인코딩 미완 영상도 제외된다 — 목록의 영상은 모두 재생 조회로 바로 진입할 수 있다. 친구가 점령하지 않은 격자·존재하지 않는 gridId 는 빈 배열이다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGridVideos","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFriendGridVideoResponseDto"}}}}}}},"/api/friends/{userId}/grids/aggregation":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 행정 단위 집계 조회 (줌아웃)","description":"지도를 축소한 시야에서 그 친구가 점령한 격자를 행정 단위로 묶어 센 목록을 페이지 없이 한 번에 반환한다. 파라미터·에러는 내 집계 조회(GET /api/grids/aggregation)와 같고, 묶음 항목의 공통 필드는 내 집계 조회와 같다. 다만 친구 응답은 currentRegion/items 겉면 없이 기존 배열로 반환한다. 단위 전환 시점은 서버가 정하지 않고 클라이언트가 화면 축척에 맞춰 unit 만 바꿔 부른다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 격자 수가 온다. 행정동이 판정되지 않은 격자(해상 등)는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 오고, 그 친구가 점령한 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용). 초과 시 400 + developCode 4402, bbox 가 뒤집히거나 파라미터가 빠지면 4401, unit 이 없거나 미지원 값이면 4405 다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGridAggregates","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"DONG"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionAggregateResponseDto"}}}}}}},"/api/friends/requests/received":{"get":{"tags":["친구 (Friend)"],"summary":"받은 친구 요청 목록","description":"내가 수신자인 대기 중 요청을 최신순으로 반환한다. 항목의 requesterId 를 수락/거절 경로 변수로 그대로 쓴다.","operationId":"getReceivedRequests","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListReceivedFriendRequestResponseDto"}}}}}}},"/api/friends/preview":{"get":{"tags":["친구 (Friend)"],"summary":"친구 코드 미리보기","description":"요청을 보내기 전 확인 화면용 — 코드 소유자의 닉네임과 나와의 관계 상태(relation)를 반환한다. relation 은 SELF(내 코드)·NONE(관계 없음)·OUTGOING_PENDING(내가 보낸 요청 대기)·INCOMING_PENDING(상대가 보낸 요청 대기)·FRIENDS(이미 친구) 다섯 값이고 조회 시점 실시간 판정이다. 미리보기는 힌트일 뿐이며 최종 검증(자기 자신·중복 등)은 요청 API 가 다시 수행한다.","operationId":"preview","parameters":[{"name":"code","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendPreviewResponseDto"}}}}}}},"/api/friends/code":{"get":{"tags":["친구 (Friend)"],"summary":"내 친구 코드 조회","description":"가입 시 자동 부여된 고정 8자 코드를 반환한다. 상대에게 임의 채널(카톡 등)로 공유하면 상대가 이 코드로 친구 요청을 보낼 수 있다. 재발급 없음.","operationId":"getMyFriendCode","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendCodeResponseDto"}}}}}}},"/api/event-videos/{videoId}":{"get":{"tags":["행사 (Events)"],"summary":"행사 영상 상세 조회","description":"영상 하나의 재생본 presigned GET URL 과 표시 재료를 돌려준다. 소속 행사 회차·위치·대표 격자와 그 표시명 재료가 함께 담겨, 상세 화면이 추가 호출 없이 위치줄을 그린다.\n\n피드에 보이는 영상만 열린다 — 삭제·블라인드·비공개·처리 미완료 영상은 올린 본인에게도 404 + developCode 13406 이다(본인 영상 확인은 GET /api/videos/{videoId}). 행사 영상이 아닌 영상 id 도 같은 404 다.\n\ninteractionLocked 는 아카이브 전환(행사 종료 + 30일)부터 true 이며 댓글·도움돼요 입력 UI 를 비활성화하는 재료다(기존 수는 계속 표시. 유예 기간에는 반응을 계속 남길 수 있다). 재생 URL 을 발급받은 타인 조회는 조회수를 올린다 — 비로그인 조회도 포함이고 올린 본인은 제외다.","operationId":"getVideoDetail","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoDetailResponseDto"}}}}}}},"/api/event-occurrences":{"get":{"tags":["행사 (Events)"],"summary":"뷰포트 내 행사 회차 목록 조회","description":"지도 화면 bbox(남서~북동 좌표) 안에 노출 영역이 걸친 행사 회차를 반환한다. 담기는 것은 진행 중이거나, 시작 2주 전부터의 노출 기간에 든 예정 회차뿐이다 — 종료된 행사(업로드 유예·아카이브)는 칩에 담기지 않고 상세·격자 역조회로만 접근한다. 아직 노출 기간 전인 예정 회차는 존재 자체를 숨긴다.\n\n정렬은 시 이름 → 시작일 → 회차 id 오름차순이라, 시 칩 아래에 그 시의 행사 칩을 나열하는 화면이 매 요청 같은 순서를 받는다. 보이는 범위에 행사가 없으면 실패가 아니라 빈 배열이다.\n\nbbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 400 + developCode 13401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히거나 파라미터가 빠지면 13400 이다. D-day 는 startsAt 을 KST 로 읽어 클라이언트가 계산한다.","operationId":"getOccurrencesInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.2},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.1}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListEventOccurrenceChipResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}":{"get":{"tags":["행사 (Events)"],"summary":"행사 회차 상세 조회","description":"이벤트 헤더 재료 — 행사명, 기간, 업로드 마감(종료 30일 후), 서버 시각 기준 상태, 알림 구독 여부, 같은 시리즈의 지난 회차 목록이다. 상태는 저장값이 아니라 요청 시점 계산이며 경계 정각은 다음 상태에 속한다(종료 정각부터 UPLOAD_GRACE).\n\n지난 회차는 최신순이고 예정 회차는 담기지 않는다. 그 회차의 위치·영상은 회차 id 로 위치 목록을 다시 부르면 되므로 회차 간 데이터가 섞이지 않는다. 알림 구독 여부는 구독을 켰으면서 회차가 예정이거나 진행 중일 때만 true 다 — 비로그인 열람과 종료된 회차는 false 다.\n\n존재하지 않는 회차와 아직 노출 기간 전인 예정 회차는 똑같이 404 + developCode 13404 다 — 노출 전 행사의 존재를 id 대입으로 알아낼 수 없다.","operationId":"getOccurrenceDetail","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventOccurrenceDetailResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/viewer-count":{"get":{"tags":["이벤트 (Event)"],"summary":"현재 열람 인원 조회","description":"viewerCount 0 은 아무도 없음(표시), null 은 캐시 장애(숨김)다. 응답이 사용자 무관이라 인증 없이 호출할 수 있다.","operationId":"getViewerCount","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventViewerCountResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/locations":{"get":{"tags":["행사 (Events)"],"summary":"행사 회차의 위치 목록 조회","description":"회차에 속한 행사 위치(팝업·체험존·퍼레이드 등)와 각 위치의 격자 영역, 대표 격자, 표시명 재료, 영상 수를 반환한다. 영상 수는 집계 테이블 없이 조회 시점에 세며, 위치별 영상 피드에 실제로 보이는 영상만 센다(삭제·비공개·처리 미완료 제외).\n\ngridIds 는 화면에서 영역을 채색하는 재료이고 영상은 그중 representativeGridId 하나에만 붙는다. 표시명은 대표 격자 기준으로 `zoneName + \" \" + zoneCell`, 구역 밖이면 regionName 을 쓴다. 정렬은 표시 순서 → 위치 id 오름차순이다. 위치가 없으면 빈 배열이고, 존재하지 않는 회차와 노출 기간 전인 예정 회차는 404 + developCode 13404 다.","operationId":"getLocations","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListEventLocationResponseDto"}}}}}}},"/api/collections/videos":{"get":{"tags":["도감 (Collection)"],"summary":"동 단위 내 영상 조회","description":"행정동(regionCode) 격자들에 올린 로그인 사용자의 영상을 created_at 내림차순으로 반환한다(무커서). regionCode 는 by-grid 응답의 regionCode 를 그대로 넘긴다. 귀속은 격자 축이라 영상 좌표가 옆 동이어도 격자 소속 행정동 기준으로 포함된다. 내 도감이라 PRIVATE·인코딩 중 영상도 포함하며(status ACTIVE 만), 그 행정동에 내 영상이 없거나 미존재 regionCode 면 에러 없이 빈 배열을 받는다.","operationId":"getRegionVideos","parameters":[{"name":"regionCode","in":"query","description":"행정동 코드 — by-grid 응답의 regionCode 를 그대로 전달","required":true,"schema":{"type":"string"},"example":1168051500}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionVideoResponseDto"}}}}}}},"/api/collections/upload-history":{"get":{"tags":["도감 (Collection)"],"summary":"날짜별 업로드 기록 조회","description":"로그인 사용자 본인의 업로드를 KST 날짜로 접어, 업로드가 있었던 날과 그날의 건수를 날짜 오름차순으로 반환한다(잔디 재료 — 빈 날은 항목 없음, 빈 칸 채우기는 FE 몫). 삭제·블라인드된 영상의 업로드도 센다. 업로드 0건 사용자는 에러 없이 빈 배열을 받는다.","operationId":"getUploadHistory","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListUploadHistoryResponseDto"}}}}}}},"/api/collections/summary":{"get":{"tags":["도감 (Collection)"],"summary":"개인 도감 요약 조회","description":"로그인 사용자의 점령한 격자 수·올린 영상 총합·방문한 행정동 수에 더해 현재 스트릭·최장 스트릭·획득 뱃지 수를 한 번에 반환한다. 현재 스트릭은 마지막 기록이 KST 그제 이전이면 0이다. 업로드 경험 0 사용자도 에러 없이 여섯 값이 모두 0으로 응답한다.","operationId":"getSummary","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoCollectionSummaryResponseDto"}}}}}}},"/api/collections/grids":{"get":{"tags":["도감 (Collection)"],"summary":"갤러리 격자 목록 조회","description":"로그인 사용자가 수집한 격자를 카드로 반환한다(무커서). 파라미터를 모두 생략하면 전국을 first_collected_at 내림차순 최대 30개로 준다(기존 계약). regionCode 를 주면 그 행정동에 속한 내 격자만 나가며, 귀속은 격자 축이라 영상 좌표가 옆 동이어도 격자 소속 행정동 기준으로 잡힌다. 각 항목은 gridId·gridY/gridX·수집/방문 시각·영상 수·cover 영상 ID·cover 썸네일 URL·cover 길이(초)를 담는다. 내 격자가 없거나 미존재 regionCode 면 에러 없이 빈 배열을 받는다.","operationId":"getCollectionGrids","parameters":[{"name":"regionCode","in":"query","description":"행정동 코드 — 생략하면 전국. by-grid 응답의 regionCode 를 그대로 전달","required":false,"schema":{"type":"string"},"example":1168051500},{"name":"sort","in":"query","description":"정렬 축 — COLLECTED(수집 시각순, 기본) 또는 UPLOADED(최신 업로드순)","required":false,"schema":{"type":"string","default":"COLLECTED","enum":["COLLECTED","UPLOADED"]}},{"name":"limit","in":"query","description":"카드 수 상한 — 지도 홈 패널은 20 (SRS FR-MAP-10). 생략하면 regionCode 없을 때 30, regionCode 있을 때 그 동네 전부. 1 미만은 1 로 보정한다","required":false,"schema":{"type":"integer","format":"int32"},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListCollectionGridResponseDto"}}}}}}},"/api/badges":{"get":{"tags":["뱃지 (Badge)"],"summary":"내 뱃지 전체 목록","description":"시딩된 뱃지를 내 획득 상태와 함께 시딩 순(badges.id 오름차순)으로 반환한다. 은퇴 뱃지(retired_at 있음)는 획득자에게만 보이고 미획득자 목록에서는 빠진다 — 그래서 사용자마다 행 수가 다를 수 있다. 미획득 행은 earned false·earnedAt null·isNew false·featuredRank null. 이번 응답에 노출된 미확인(새 뱃지) 행은 자동으로 확인 처리되어 다음 조회부터 isNew false 가 된다.","operationId":"findMyBadges","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMyBadgeResponseDto"}}}}}}},"/api/auth/password/status":{"get":{"tags":["비밀번호 (Password)"],"summary":"비밀번호 강제 변경 상태 조회","description":"true 면 초기 비밀번호 상태라 행사 등재 콘솔(/api/org/**)이 전부 막힌다. 비밀번호가 없는 소셜 계정은 항상 false 다.","operationId":"getStatus","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoPasswordStatusResponseDto"}}}}}}},"/api/auth/oauth/kakao/authorize":{"get":{"tags":["인증 (Auth)"],"summary":"카카오 로그인 시작 (인가 진입점)","description":"웹 로그인의 시작점이다. 클라이언트는 이 URL 로 이동하기만 하면 된다(location.href). 서버가 카카오 인가 URL(client_id·response_type=code·scope=openid·nonce 포함)을 조립해 302 로 보내면서 같은 응답에 OAUTH_NONCE 쿠키(HttpOnly, 10분)를 심는다. 그래서 scope=openid 누락이나 nonce 누락이 구조적으로 불가능하고, REST API 키가 클라이언트 코드로 나갈 일도 없다. 응답은 리다이렉트라 공통 응답 포맷을 쓰지 않는다.","operationId":"redirectToKakaoAuthorize","parameters":[{"name":"redirectUri","in":"query","description":"카카오 콜백 URI. 콘솔 등록값과 정확히 일치해야 한다(검증 주체는 카카오).","required":true,"schema":{"type":"string"},"example":"http://localhost:5173/oauth/kakao/callback"},{"name":"state","in":"query","description":"콜백 위조 검증용 난수. 서버는 손대지 않고 인가 URL 에 그대로 전달한다.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/admin/videos/{videoId}":{"get":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"관리자 단건 영상 확인","description":"신고 판단용으로 영상 하나를 확인한다 — 공개범위와 상태(BLINDED 포함)를 무시하고 요청 시점에 재생·썸네일 presigned URL 을 발급하며, 조회수를 올리지 않는다. 처리 상태가 READY 가 아니면 playbackUrl 과 expiresInSec 은 null 이다. 없는 영상과 삭제된 영상은 404(3404) 다.","operationId":"getVideoForReview","parameters":[{"name":"videoId","in":"path","description":"확인할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminVideoReviewResponseDto"}}}}}}},"/api/admin/reports":{"get":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 목록 조회","description":"상태 필터 기준으로 신고를 접수 최신순 페이지 단위로 조회한다. 기본은 미처리(PENDING) 신고다. 항목에 신고자·영상 소유자 닉네임과 영상 현재 상태가 함께 담겨 목록만으로 판단할 수 있다. 지원하지 않는 status 는 400(11420), page 음수나 size 범위(1~100) 밖은 400(11421) 이다. REVIEWING 은 유효한 값이지만 만드는 경로가 없어 항상 빈 목록이다.","operationId":"getReports","parameters":[{"name":"status","in":"query","description":"신고 상태 필터 (PENDING, REVIEWING, RESOLVED, REJECTED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"PENDING"},"example":"PENDING"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportListResponseDto"}}}}}}},"/api/admin/org-account-requests":{"get":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"계정 발급 요청 목록 조회","description":"상태 필터 기준으로 발급 요청을 마지막 접수 최신순 페이지 단위로 조회한다. 기본은 대기(PENDING) 요청이다. 상태별 건수 3종이 필터와 무관하게 함께 실려 탭 뱃지를 그릴 수 있다.\n\n지원하지 않는 status 는 400(1424), page 음수나 size 범위(1~100) 밖은 400(1425) 이다.","operationId":"getRequests","parameters":[{"name":"status","in":"query","description":"처리 상태 필터 (PENDING, ISSUED, REJECTED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"PENDING"},"example":"PENDING"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminOrgAccountRequestListResponseDto"}}}}}}},"/api/admin/org-account-requests/{requestId}":{"get":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"계정 발급 요청 상세 조회","description":"접수 필드 전체와 처리 결과를 조회한다. 응답의 updatedAt 은 승인·반려 요청에 그대로 되돌려 보내야 하는 검토 기준 시각이다 — 검토와 처리 사이에 신청 내용이 바뀌면 그 값으로 걸러진다.\n\n없는 요청은 404(1421) 다.","operationId":"getRequest","parameters":[{"name":"requestId","in":"path","description":"조회할 요청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminOrgAccountRequestDetailResponseDto"}}}}}}},"/api/admin/events":{"get":{"tags":["관리자 승인 행사 (Admin Approved Event)"],"summary":"승인 행사 목록 조회","description":"승인된 행사를 노출 중(EXPOSED)·예정(UPCOMING)·종료(ENDED) 탭으로 조회한다. 기본은 노출 중이다. 상태는 저장값이 아니라 조회 시점 KST 오늘과 행사 기간으로 파생하므로 시작일 당일은 노출 중, 종료일 당일도 노출 중이고 그 다음 날부터 종료다.\n\n탭 건수 3종은 탭과 무관한 전체 집계라 화면 뱃지에 그대로 쓴다. 노출이 중지된 행사도 탭에 그대로 남고 unpublished·unpublishedAt·unpublishReason 으로 구분된다 — 무엇을 왜 내렸는지 관리자가 계속 확인할 수 있어야 하기 때문이다.\n\n지원하지 않는 status 는 400(13455), page 음수나 size 범위(1~100) 밖은 400(13456) 이다.","operationId":"getEvents","parameters":[{"name":"status","in":"query","description":"탭 필터 (EXPOSED, UPCOMING, ENDED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"EXPOSED"},"example":"EXPOSED"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminApprovedEventListResponseDto"}}}}}}},"/api/admin/event-submissions":{"get":{"tags":["관리자 행사 등재 심사 (Admin Event Submission)"],"summary":"심사 큐 조회","description":"상태 필터 기준으로 신청을 접수 최신순 페이지 단위로 조회한다. 기본은 심사 중(IN_REVIEW) 신청이다. 상태별 건수 3종이 필터와 무관하게 함께 실려 탭 뱃지를 그릴 수 있다.\n\n항목의 organizerName 은 신청 폼의 주최 기관이고 orgName 은 신청 계정에 등록된 기관명이라, 둘이 다르면 그 자체가 심사 신호다.\n\n지원하지 않는 status 는 400(13455), page 음수나 size 범위(1~100) 밖은 400(13456) 이다.","operationId":"getSubmissions","parameters":[{"name":"status","in":"query","description":"신청 상태 필터 (IN_REVIEW, APPROVED, REJECTED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"IN_REVIEW"},"example":"IN_REVIEW"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminEventSubmissionListResponseDto"}}}}}}},"/api/admin/event-submissions/{submissionId}":{"get":{"tags":["관리자 행사 등재 심사 (Admin Event Submission)"],"summary":"심사 상세 조회","description":"신청 폼 필드 전체(대표 이미지는 presigned GET URL)에 심사 재료를 더해 조회한다 — 신청 계정 정보, 전 위치를 감싸는 노출 영역 사각형, 상태 이력이다. 노출 영역은 조회 시점 계산값이라 저장되지 않는다.\n\n관리자 조회에는 존재 은닉이 없다 — 없는 신청은 그대로 404(13430) 다.","operationId":"getSubmission_1","parameters":[{"name":"submissionId","in":"path","description":"조회할 신청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminEventSubmissionDetailResponseDto"}}}}}}},"/api/admin/email-change-requests":{"get":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"아이디 변경 요청 목록 조회","description":"행사 운영자가 낸 아이디(공식 이메일) 변경 요청을 상태 필터 기준으로 접수 최신순 조회한다. 기본은 대기(PENDING) 요청이다. 항목에 현재 아이디와 바꾸려는 이메일이 나란히 실려 그대로 대조할 수 있고, 상태별 건수 3종이 필터와 무관하게 함께 온다.\n\n응답의 createdAt 은 승인·반려 요청에 되돌려 보내야 하는 검토 기준 시각이다 — 재요청은 같은 대기 행을 덮어쓰므로, 이 값으로 걸러야 본 적 없는 이메일을 승인하는 사고가 없다.\n\n지원하지 않는 status 는 400(1424), page 음수나 size 범위(1~100) 밖은 400(1425) 이다.","operationId":"getEmailChangeRequests","parameters":[{"name":"status","in":"query","description":"처리 상태 필터 (PENDING, APPROVED, REJECTED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"PENDING"},"example":"PENDING"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminEmailChangeRequestListResponseDto"}}}}}}},"/api/friends/{userId}":{"delete":{"tags":["친구 (Friend)"],"summary":"친구 삭제","description":"친구 관계를 해소한다. 어느 쪽이든 삭제할 수 있고 즉시 양쪽 모두에서 사라진다. 대기 중 요청은 대상이 아니다.","operationId":"deleteFriend","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}}},"components":{"schemas":{"VideoReplaceRequestDto":{"type":"object","description":"영상 교체 요청. 파일만 바꾸려면 좌표를 생략한다. 좌표를 보내면 기존과 같은 격자여야 하며 다르면 GRID_MISMATCH로 거부된다.","properties":{"s3Key":{"type":"string","description":"새로 업로드한 영상의 S3 객체 키","example":"videos/2026/07/new-uuid.mp4","minLength":1},"lat":{"type":["number","null"],"format":"double","description":"위도 (선택). lng와 함께 보내거나 둘 다 생략","example":37.5665},"lng":{"type":["number","null"],"format":"double","description":"경도 (선택). lat과 함께 보내거나 둘 다 생략","example":126.978},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-17T14:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoVideoReplaceResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoReplaceResponseDto"}},"required":["data","developCode","message"]},"VideoReplaceResponseDto":{"type":"object","description":"영상 교체 응답. 교체 직후는 항상 재인코딩 대기(UPLOADED) 상태다.","properties":{"videoId":{"type":"integer","format":"int64","description":"교체된 영상 ID","example":1001},"processingStatus":{"type":"string","description":"영상 처리 상태 (교체 직후 UPLOADED)","example":"UPLOADED"}},"required":["processingStatus","videoId"]},"ProfileImageUpdateRequestDto":{"type":"object","description":"프로필 이미지 변경 확정 요청 (MSG-373)","properties":{"s3Key":{"type":"string","description":"presign 발급으로 받은 pending 키. 그 URL 로 업로드를 마친 뒤 그대로 전달한다.","example":"profiles/pending/42/3f0c1f2e-....jpg","minLength":1}},"required":["s3Key"]},"ApiResponseDtoUserProfileResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/UserProfileResponseDto"}},"required":["data","developCode","message"]},"UserProfileResponseDto":{"type":"object","description":"내 프로필 응답. 조회·닉네임 수정·프로필 이미지 변경·위치정보 동의 변경이 같은 형태를 반환한다.","properties":{"email":{"type":["string","null"],"description":"가입 이메일 — 이메일 가입 시 저장된 값. 카카오 가입은 이메일을 수집하지 않아 null (MSG-310)","example":"user@fillmap.dev"},"nickname":{"type":"string","description":"닉네임 — 카카오 로그인 시 카카오 닉네임이 자동 저장되며, 이후 수정 가능","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"프로필 이미지 공개 URL — 미설정이면 null 이고 기본 프로필 표시는 FE 몫이다 (MSG-373)","example":"https://fillmap-video-dev.s3.ap-northeast-2.amazonaws.com/profiles/original/42/uuid.jpg"},"createdAt":{"type":"string","format":"date-time","description":"가입 시각 — DB 저장값(UTC) 그대로다. \"2026.01.12\" 같은 표기는 FE 몫 (MSG-373)","example":"2026-01-12T03:24:11Z"},"locationConsent":{"type":"boolean","description":"위치기반서비스 이용 동의 여부 — 가입 직후는 false 다. 마지막 변경 시각은 서버에만 두고 응답에 싣지 않는다 (MSG-402 §D-6)","example":false},"role":{"type":"string","description":"사용자 역할 — 화면이 일반 사용자·행사 운영자·관리자 진입을 가르는 재료다 (MSG-496)","enum":["USER","ORG","ADMIN"],"example":"USER"}},"required":["createdAt","email","locationConsent","nickname","profileImageUrl","role"]},"NicknameUpdateRequestDto":{"type":"object","description":"닉네임 수정 요청","properties":{"nickname":{"type":"string","description":"새 닉네임 (2~20자)","example":"채우미","maxLength":20,"minLength":2}},"required":["nickname"]},"MarketingConsentUpdateRequestDto":{"type":"object","description":"마케팅 정보 수신 동의 변경 요청","properties":{"consented":{"type":"boolean","description":"true 면 동의, false 면 철회","example":true}},"required":["consented"]},"ApiResponseDtoConsentStatusResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ConsentStatusResponseDto"}},"required":["data","developCode","message"]},"ConsentStatusResponseDto":{"type":"object","description":"가입 약관 동의 상태. 조회·제출·마케팅 변경이 같은 형태를 반환한다.","properties":{"ageOver14":{"type":"boolean","description":"만 14세 이상 확인 여부 (필수). 자기 확인 체크 사실만 저장하며 생년월일은 수집하지 않는다","example":true},"serviceTerms":{"type":"boolean","description":"서비스 이용약관 동의 여부 (필수)","example":true},"privacyPolicy":{"type":"boolean","description":"개인정보 수집·이용 동의 여부 (필수)","example":true},"locationTerms":{"type":"boolean","description":"위치기반서비스 이용약관 동의 여부 (필수). 프로필 화면의 위치정보 사용 동의와 같은 한 값이며 철회할 수 없다 — 한 번 true 가 되면 되돌아가지 않는다","example":true},"marketing":{"type":"boolean","description":"마케팅 정보 수신 동의 여부 (선택). 가입 후에도 전용 API 로 켜고 끌 수 있다","example":false},"requiredCompleted":{"type":"boolean","description":"필수 4항목을 전부 동의했으면 true. false 면 클라이언트가 동의 게이트를 띄운다","example":true}},"required":["ageOver14","locationTerms","marketing","privacyPolicy","requiredCompleted","serviceTerms"]},"LocationConsentUpdateRequestDto":{"type":"object","description":"위치정보 사용 동의 켜기 요청","properties":{"consented":{"type":"boolean","description":"true 면 동의. 이 동의는 철회할 수 없어 false 는 1400 으로 거절된다","example":true}},"required":["consented"]},"ConsentSubmitRequestDto":{"type":"object","description":"가입 약관 동의 제출 요청. 필수 4항목은 true 여야 하고 마케팅만 선택이다.","properties":{"ageOver14":{"type":"boolean","description":"만 14세 이상 확인 (필수, true 만 허용)","example":true},"serviceTerms":{"type":"boolean","description":"서비스 이용약관 동의 (필수, true 만 허용)","example":true},"privacyPolicy":{"type":"boolean","description":"개인정보 수집·이용 동의 (필수, true 만 허용)","example":true},"locationTerms":{"type":"boolean","description":"위치기반서비스 이용약관 동의 (필수, true 만 허용)","example":true},"marketing":{"type":"boolean","description":"마케팅 정보 수신 동의 (선택). true·false 모두 유효하되 누락은 400 이다","example":false}},"required":["ageOver14","locationTerms","marketing","privacyPolicy","serviceTerms"]},"ApiResponseDtoEventVideoHelpfulResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoHelpfulResponseDto"}},"required":["data","developCode","message"]},"EventVideoHelpfulResponseDto":{"type":"object","description":"행사 영상 도움돼요 변경 결과","properties":{"helpfulCount":{"type":"integer","format":"int64","description":"처리 후 현재 도움돼요 수","example":12},"helpfulByMe":{"type":"boolean","description":"내가 누른 상태인지","example":true}},"required":["helpfulByMe","helpfulCount"]},"EventNotificationUpdateRequestDto":{"type":"object","description":"행사 알림 구독 토글","properties":{"enabled":{"type":"boolean","description":"구독 여부 — true 면 ON, false 면 OFF","example":true}},"required":["enabled"]},"ApiResponseDtoEventNotificationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventNotificationResponseDto"}},"required":["data","developCode","message"]},"EventNotificationResponseDto":{"type":"object","description":"행사 알림 구독 상태","properties":{"enabled":{"type":"boolean","description":"구독 여부 — 구독 행 존재이면서 회차가 예정·진행 중일 때만 true","example":true}},"required":["enabled"]},"FeaturedBadgeRequestDto":{"type":"object","description":"대표 뱃지 집합 교체 요청 — 배열 순서가 표시 순서, 빈 배열은 전부 해제","properties":{"badgeIds":{"type":"array","description":"대표로 지정할 뱃지 id 목록 (최대 2개, 순서 = 표시 순서)","example":[3,7],"items":{"type":"integer","format":"int64"},"maxItems":2,"minItems":0}},"required":["badgeIds"]},"ApiResponseDtoListFeaturedBadgeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FeaturedBadgeResponseDto"}}},"required":["data","developCode","message"]},"FeaturedBadgeResponseDto":{"type":"object","description":"적용된 대표 뱃지","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":3},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_50"},"name":{"type":"string","description":"표시명","example":"탐험가 II"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null},"rank":{"type":"integer","format":"int32","description":"표시 순서 (1·2)","example":1}},"required":["badgeId","code","iconUrl","name","rank"]},"VideoUploadRequestDto":{"type":"object","description":"S3 업로드 완료 후 영상 메타데이터 저장 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키","example":"videos/2026/07/uuid.mp4","minLength":1},"lat":{"type":"number","format":"double","description":"촬영 위치 위도 (격자 매핑에 사용)","example":37.5665},"lng":{"type":"number","format":"double","description":"촬영 위치 경도 (격자 매핑에 사용)","example":126.978},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-17T14:30:00Z"},"visibility":{"type":"string","description":"공개범위. PUBLIC, PRIVATE, FRIENDS 중 하나. 생략 시 PUBLIC","example":"PUBLIC"}},"required":["durationSec","lat","lng","recordedAt","s3Key"]},"ApiResponseDtoVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoUploadResponseDto"}},"required":["data","developCode","message"]},"CompletedMissionResponseDto":{"type":"object","description":"이번 업로드로 완료된 미션 스탬프","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 ID","example":3},"title":{"type":"string","description":"미션 제목","example":"성수 골목 코스"},"type":{"type":"string","description":"미션 유형 (COURSE/AREA/EVENT/THEME/CONTINUOUS)","example":"COURSE"}},"required":["missionId","title","type"]},"EarnedBadgeResponseDto":{"type":"object","description":"이번 행동으로 새로 획득한 뱃지","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":1},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_1"},"name":{"type":"string","description":"표시명","example":"첫 발자국"},"description":{"type":["string","null"],"description":"설명 — badges.description 은 NULL 허용 컬럼이다","example":"첫 격자를 수집했어요"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null}},"required":["badgeId","code","description","iconUrl","name"]},"VideoUploadResponseDto":{"type":"object","description":"영상 메타데이터 저장 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"매핑된 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 격자를 처음 점령(첫 방문)했는지 여부","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}},"completedMissions":{"type":"array","description":"이 업로드로 완료된 미션 스탬프 목록 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/CompletedMissionResponseDto"}},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속(해상 등)이거나 미판정이면 null","example":"서울특별시 강남구 역삼1동"}},"required":["completedMissions","gridId","newBadges","occupied","processingStatus","regionName","videoId","zoneCell","zoneName"]},"ReportCreateRequestDto":{"type":"object","description":"영상 신고 접수 요청. 사유 5종 중 하나와 선택적 상세 설명.","properties":{"reason":{"type":"string","description":"신고 사유. INAPPROPRIATE, PRIVACY, SPAM, COPYRIGHT, OTHER 중 하나 (대소문자 무관)","example":"INAPPROPRIATE","minLength":1},"detail":{"type":"string","description":"상세 설명. OTHER 사유는 필수, 나머지 사유는 선택. 최대 500자","example":"타인의 얼굴이 그대로 찍혀 있습니다","maxLength":500,"minLength":0}},"required":["reason"]},"ApiResponseDtoReportCreateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ReportCreateResponseDto"}},"required":["data","developCode","message"]},"ReportCreateResponseDto":{"type":"object","description":"영상 신고 접수 응답.","properties":{"reportId":{"type":"integer","format":"int64","description":"접수된 신고 ID","example":17},"status":{"type":"string","description":"신고 처리 상태. 접수 직후라 항상 PENDING","example":"PENDING"}},"required":["reportId","status"]},"PresignedUrlRequestDto":{"type":"object","description":"S3 업로드용 presigned URL 발급 요청","properties":{"extension":{"type":"string","description":"영상 파일 확장자 (점 없이)","example":"mp4","minLength":1},"contentType":{"type":"string","description":"영상 MIME 타입","example":"video/mp4","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 서버 상한 초과 시 거부","example":10485760},"purpose":{"type":"string","description":"발급 용도. 미지정(null)은 UPLOAD 와 동일. 하이라이트 선분석 원본은 HIGHLIGHT_PREVIEW 로 발급받아 전용 크기 상한(기본 2GiB)을 적용받는다","example":"HIGHLIGHT_PREVIEW","pattern":"UPLOAD|HIGHLIGHT_PREVIEW"}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoPresignedUrlResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/PresignedUrlResponseDto"}},"required":["data","developCode","message"]},"PresignedUrlResponseDto":{"type":"object","description":"presigned URL 발급 응답. uploadUrl로 S3에 직접 PUT 업로드 후, s3Key로 메타데이터 저장(POST /api/videos)을 호출한다.","properties":{"uploadUrl":{"type":"string","description":"S3에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/videos/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 이후 메타데이터 저장 요청에 그대로 전달한다.","example":"videos/2026/07/uuid.mp4"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":300}},"required":["expiresInSec","s3Key","uploadUrl"]},"HighlightPreviewRequestDto":{"type":"object","description":"하이라이트 선분석 요청 (MSG-351). 원본은 presign(purpose=HIGHLIGHT_PREVIEW)으로 먼저 올린다.","properties":{"s3Key":{"type":"string","description":"presign 으로 올린 원본의 pending 키. videos/pending/{내 userId}/ prefix 여야 한다","example":"videos/pending/42/550e8400-e29b-41d4-a716-446655440000.mp4","minLength":1}},"required":["s3Key"]},"ApiResponseDtoHighlightPreviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/HighlightPreviewResponseDto"}},"required":["data","developCode","message"]},"HighlightPreviewResponseDto":{"type":"object","description":"하이라이트 선분석 응답 (MSG-351). 결과는 저장되지 않는 임시 값이다 — 확정본의 하이라이트는 업로드 확정 후 블러 파이프라인이 따로 계산한다.","properties":{"highlights":{"type":"array","description":"[[시작초, 끝초], ...] 최대 3구간, 초는 소수점 둘째 자리. 배열 순서가 추천 우선순위(첫 요소가 최우선)다. 각 구간은 5초 이상이고 시작점끼리 5초 이상 벌어진다. 5초 미만 원본이거나 조건을 채우는 구간이 없으면 빈 배열 [] — 추천 없음이니 FE 는 추천 단계를 스킵한다","example":[[0.0,5.12],[10.0,16.4]],"items":{"type":"array","items":{"type":"number","format":"double"}}}},"required":["highlights"]},"ProfileImagePresignRequestDto":{"type":"object","description":"프로필 이미지 업로드용 presigned URL 발급 요청 (MSG-373)","properties":{"extension":{"type":"string","description":"이미지 파일 확장자 (점 없이). jpg, jpeg, png, webp — heic·heif 는 받지 않는다","example":"jpg","minLength":1},"contentType":{"type":"string","description":"이미지 MIME 타입. 확장자와 쌍이 맞아야 한다","example":"image/jpeg","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 5MB 초과 시 거부","example":1048576}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoProfileImagePresignResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ProfileImagePresignResponseDto"}},"required":["data","developCode","message"]},"ProfileImagePresignResponseDto":{"type":"object","description":"프로필 이미지 presigned URL 발급 응답. uploadUrl 로 S3 에 직접 PUT 업로드한 뒤 s3Key 로 변경 확정(PUT /api/users/me/profile-image)을 호출한다.","properties":{"uploadUrl":{"type":"string","description":"S3 에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/profiles/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 변경 확정 요청에 그대로 전달한다.","example":"profiles/pending/42/3f0c1f2e-....jpg"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":600}},"required":["expiresInSec","s3Key","uploadUrl"]},"RouteWalkPathRequestDto":{"type":"object","description":"보행 경로 조회 요청","properties":{"segments":{"type":"array","description":"추천 응답의 이웃 좌표쌍 목록 (1~8개 — 지점 상한 8이라 세그먼트 최대 7개에 출발지 구간 1개)","items":{"$ref":"#/components/schemas/SegmentDto"}}}},"SegmentDto":{"type":"object","description":"이웃 두 지점 사이 구간 (WGS84)","properties":{"startLat":{"type":"number","format":"double","description":"출발 위도","example":35.1587},"startLng":{"type":"number","format":"double","description":"출발 경도","example":129.1604},"endLat":{"type":"number","format":"double","description":"도착 위도","example":35.1631},"endLng":{"type":"number","format":"double","description":"도착 경도","example":129.1635}}},"ApiResponseDtoRouteWalkPathResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RouteWalkPathResponseDto"}},"required":["data","developCode","message"]},"PathPointDto":{"type":"object","description":"보행로 좌표 (WGS84)","properties":{"lat":{"type":"number","format":"double","description":"위도","example":35.1587},"lng":{"type":"number","format":"double","description":"경도","example":129.1604}},"required":["lat","lng"]},"RouteWalkPathResponseDto":{"type":"object","description":"보행 경로 조회 응답 — 요청과 같은 개수, 같은 순서","properties":{"segments":{"type":"array","description":"세그먼트별 보행 경로 결과","items":{"$ref":"#/components/schemas/WalkSegmentDto"}}},"required":["segments"]},"WalkSegmentDto":{"type":"object","description":"세그먼트 보행 경로","properties":{"resolved":{"type":"boolean","description":"보행 경로 확보 여부 — false 면 직선 폴백"},"path":{"type":["array","null"],"description":"보행로를 따르는 좌표열 (위도-경도 순). 실패 시 null","items":{"$ref":"#/components/schemas/PathPointDto"}},"distanceMeters":{"type":["integer","null"],"format":"int32","description":"실제 걷는 거리 (TMap totalDistance, 미터). 실패 시 null"}},"required":["distanceMeters","path","resolved"]},"OriginDto":{"type":"object","description":"출발 지점 좌표","properties":{"lat":{"type":"number","format":"double","description":"위도","example":35.115,"maximum":90.0,"minimum":-90.0},"lng":{"type":"number","format":"double","description":"경도","example":129.042,"maximum":180.0,"minimum":-180.0}},"required":["lat","lng"]},"RouteRecommendRequestDto":{"type":"object","description":"AI 경로 추천 요청","properties":{"text":{"type":"string","description":"하고 싶은 일 자연어 한 문장 (trim 후 1~500자)","example":"부산역 내려서 해운대에서 밥 먹고 축제도 보고 싶어","maxLength":500,"minLength":0},"viewport":{"$ref":"#/components/schemas/ViewportDto","description":"지금 보고 있는 지도 범위 (WGS84 사각형)"},"origin":{"$ref":"#/components/schemas/OriginDto","description":"출발 지점 좌표 (선택). 있으면 동선이 여기서 시작한다"}},"required":["text","viewport"]},"ViewportDto":{"type":"object","description":"WGS84 뷰포트 사각형","properties":{"minLat":{"type":"number","format":"double","description":"남서 위도","example":35.05},"minLng":{"type":"number","format":"double","description":"남서 경도","example":128.95},"maxLat":{"type":"number","format":"double","description":"북동 위도","example":35.25},"maxLng":{"type":"number","format":"double","description":"북동 경도","example":129.2}},"required":["maxLat","maxLng","minLat","minLng"]},"ApiResponseDtoRouteRecommendResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RouteRecommendResponseDto"}},"required":["data","developCode","message"]},"MentionedAreaDto":{"type":"object","description":"언급 지역 신호 — 지도 이동(MOVE)·축소(ZOOM_OUT) 제안의 이름·중심·범위 재료","properties":{"name":{"type":"string","description":"지역의 정식 표기 — 행정구역 매칭 단위 토큰 또는 구역 통칭(zones.name)","example":"부산광역시"},"centerLat":{"type":"number","format":"double","description":"지역 중심 위도 (WGS84) — 행정구역은 경계 무게중심, 구역은 외접 사각형 중점","example":35.1985},"centerLng":{"type":"number","format":"double","description":"지역 중심 경도 (WGS84)","example":129.0538},"minLat":{"type":"number","format":"double","description":"외접 사각형 남단 위도 (WGS84)","example":35.0512},"minLng":{"type":"number","format":"double","description":"외접 사각형 서단 경도 (WGS84)","example":128.7602},"maxLat":{"type":"number","format":"double","description":"외접 사각형 북단 위도 (WGS84)","example":35.3891},"maxLng":{"type":"number","format":"double","description":"외접 사각형 동단 경도 (WGS84)","example":129.2723},"kind":{"type":"string","description":"신호 종류 — MOVE(뷰포트와 안 겹침, 이동 제안)·ZOOM_OUT(겹치지만 뚜렷이 좁음, 축소 제안)","example":"MOVE"}},"required":["centerLat","centerLng","kind","maxLat","maxLng","minLat","minLng","name"]},"RoutePointDto":{"type":"object","description":"추천 지점","properties":{"order":{"type":"integer","format":"int32","description":"방문 순서 (1부터 연속)","example":1},"name":{"type":"string","description":"지점 이름 (원문 그대로 — AI 로 보낼 때만 100자 절단)","example":"해운대 빛축제"},"kind":{"type":"string","description":"지점 종류 — MISSION_FESTIVAL·MISSION_POPUP·MISSION_COURSE·EVENT·PLACE. FE 마커 분기용","example":"MISSION_FESTIVAL"},"lat":{"type":"number","format":"double","description":"대표 좌표 위도 (WGS84)","example":35.1587},"lng":{"type":"number","format":"double","description":"대표 좌표 경도 (WGS84)","example":129.1604},"gridId":{"type":"string","description":"격자 ID — 대표 좌표를 GridEncoder 로 즉석 계산","example":"16941_11439"},"zoneName":{"type":["string","null"],"description":"표시명 구역 이름 (MSG-341). 구역 밖이면 zoneCell 과 쌍으로 null"},"zoneCell":{"type":["string","null"],"description":"표시명 구역 셀","example":"B-3"},"regionName":{"type":["string","null"],"description":"행정동 폴백 재료 (MSG-349 정책 동일). 무귀속이면 null"},"reason":{"type":"string","description":"추천 이유 한 줄 — AI explain 응답의 reasons 항목 그대로 (FR-ROUTE-05)"},"missionId":{"type":["integer","null"],"format":"int64","description":"미션 후보면 미션 id — FE 가 미션 상세로 잇는 데 쓴다"},"occurrenceId":{"type":["integer","null"],"format":"int64","description":"행사 후보면 회차 id"}},"required":["gridId","kind","lat","lng","missionId","name","occurrenceId","order","reason","regionName","zoneCell","zoneName"]},"RouteRecommendResponseDto":{"type":"object","description":"AI 경로 추천 응답","properties":{"points":{"type":"array","description":"방문 순서대로 정렬된 지점 목록 (최대 8개)","items":{"$ref":"#/components/schemas/RoutePointDto"}},"notice":{"type":["string","null"],"description":"안내 문구 — 후보 부족(0~2개)이면 부족 안내, 여행과 무관한 문장(MSG-513)이면 무관 안내. 지점 3개 이상 정상 추천이면 null"},"mentionedArea":{"anyOf":[{"$ref":"#/components/schemas/MentionedAreaDto"},{"type":"null"}],"description":"언급 지역 신호 (MSG-468) — 문장이 화면 밖 지역을 말했으면 이동·축소 제안 재료가 실린다. 무신호(지역 무언급·동명 다수·대조 실패·충분히 담김)가 기본값"}},"required":["mentionedArea","notice","points"]},"EventSubmissionAreaRectDto":{"type":"object","description":"위치 영역 사각형 (격자 인덱스). 위치 하나의 합집합은 최대 81칸이다.","properties":{"minGridY":{"type":"integer","format":"int32","description":"격자 행 인덱스 최소","example":16859},"maxGridY":{"type":"integer","format":"int32","description":"격자 행 인덱스 최대","example":16861},"minGridX":{"type":"integer","format":"int32","description":"격자 열 인덱스 최소","example":11509},"maxGridX":{"type":"integer","format":"int32","description":"격자 열 인덱스 최대","example":11515}},"required":["maxGridX","maxGridY","minGridX","minGridY"]},"EventSubmissionCreateRequestDto":{"type":"object","description":"행사 등재 신청 제출 요청","properties":{"type":{"type":"string","description":"등록 유형 — FESTIVAL(지역축제)·POPUP(팝업스토어)·EVENT(이벤트 참여형)","enum":["FESTIVAL","POPUP","EVENT"],"example":"FESTIVAL"},"parentOccurrenceId":{"type":["integer","null"],"format":"int64","description":"참여할 승인 이벤트 회차 id — EVENT 전용 필수. 승인 이벤트 목록 응답의 occurrenceId 를 그대로 넣는다. 없는 회차면 13440, 이미 종료된 회차면 13441, 다른 유형에 실려 오면 13439","example":1},"title":{"type":"string","description":"축제명 / 팝업명","example":"부산불꽃축제","maxLength":100,"minLength":0},"organizerName":{"type":"string","description":"주최 기관 / 브랜드·운영사","example":"부산문화관광축제조직위원회","maxLength":100,"minLength":0},"startsOn":{"type":"string","format":"date","description":"행사 시작일 (KST 날짜)","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일 (KST 날짜). 오늘 이전이면 13433","example":"2026-11-07"},"operatingHours":{"type":"string","description":"운영 시간 — POPUP 전용 필수. FESTIVAL 에 실려 오면 13439","example":"11:00 ~ 20:00","maxLength":100,"minLength":0},"programDescription":{"type":"string","description":"주요 프로그램 — FESTIVAL 전용 필수. 다른 유형에 실려 오면 13439","example":"멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영","maxLength":2000,"minLength":10},"participationMethod":{"type":"string","description":"참여 방식 — EVENT 전용 필수. 다른 유형에 실려 오면 13439","example":"부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다","maxLength":2000,"minLength":10},"description":{"type":"string","description":"행사 소개","example":"광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제","maxLength":2000,"minLength":10},"imageS3Key":{"type":"string","description":"대표 이미지의 pending S3 키. presign 발급 응답의 s3Key 를 그대로 넣는다.","example":"event-submissions/pending/12/3f0c1f2e-....jpg","minLength":1},"locations":{"type":"array","description":"행사 위치 목록. 1개 이상 20개 이하이고 이름 필드가 없다.","items":{"$ref":"#/components/schemas/EventSubmissionLocationRequestDto"}}},"required":["description","endsOn","imageS3Key","organizerName","startsOn","title","type"]},"EventSubmissionLocationRequestDto":{"type":"object","description":"신청 위치 — 영역 사각형 목록만 담는다 (이름 없음)","properties":{"areaRects":{"type":"array","description":"영역 사각형 목록. 겹쳐도 되고 합집합 크기로 81칸 상한을 판정한다.","items":{"$ref":"#/components/schemas/EventSubmissionAreaRectDto"}}}},"ApiResponseDtoEventSubmissionSubmitResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventSubmissionSubmitResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionSubmitResponseDto":{"type":"object","description":"신청 접수 결과","properties":{"id":{"type":"integer","format":"int64","description":"신청 id","example":7},"submissionNo":{"type":"string","description":"신청 번호 — FM-{KST 연도}-{4자리 순번}","example":"FM-2026-0007"},"status":{"type":"string","description":"신청 상태","example":"IN_REVIEW"}},"required":["id","status","submissionNo"]},"EventSubmissionImagePresignRequestDto":{"type":"object","description":"행사 신청 대표 이미지 업로드용 presigned URL 발급 요청 (MSG-498)","properties":{"extension":{"type":"string","description":"이미지 파일 확장자 (점 없이). jpg, jpeg, png 만 — 시안 문구가 \"JPG 또는 PNG\"라 webp 는 받지 않는다","example":"jpg","minLength":1},"contentType":{"type":"string","description":"이미지 MIME 타입. 확장자와 쌍이 맞아야 한다","example":"image/jpeg","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 10MB 초과 시 거부","example":1048576}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoEventSubmissionImagePresignResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventSubmissionImagePresignResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionImagePresignResponseDto":{"type":"object","description":"행사 신청 대표 이미지 presigned URL 발급 응답. uploadUrl 로 S3 에 직접 PUT 업로드한 뒤 s3Key 를 신청 제출·재제출 요청의 imageS3Key 로 전달한다.","properties":{"uploadUrl":{"type":"string","description":"S3 에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/event-submissions/pending/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 제출·재제출 요청에 그대로 전달한다.","example":"event-submissions/pending/12/3f0c1f2e-....jpg"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":600}},"required":["expiresInSec","s3Key","uploadUrl"]},"OrgEmailChangeRequestDto":{"type":"object","description":"아이디(공식 이메일) 변경 요청","properties":{"requestedEmail":{"type":"string","format":"email","description":"바꾸려는 공식 이메일","example":"new-organizer@fillmap.dev","maxLength":255,"minLength":0}},"required":["requestedEmail"]},"OrgAccountRequestCreateRequestDto":{"type":"object","description":"행사 운영자 계정 발급 요청 (비로그인 공개 폼)","properties":{"orgName":{"type":"string","description":"기관명","example":"부산진구청","maxLength":100,"minLength":0},"contactName":{"type":"string","description":"담당자 이름 (2~20자). 승인 시 계정 담당자 이름이 되므로 계정 설정과 같은 제약이다","example":"김담당","maxLength":20,"minLength":2},"contactPhone":{"type":"string","description":"담당자 연락처. 숫자로 시작하고 끝나는 숫자·하이픈 9~20자","example":"010-1234-5678","minLength":1,"pattern":"^[0-9][0-9-]{7,18}[0-9]$"},"email":{"type":"string","format":"email","description":"공식 이메일. 승인 시 계정 아이디이자 초기 비밀번호를 받을 주소다","example":"event@busanjin.go.kr","maxLength":255,"minLength":0},"eventName":{"type":"string","description":"예정 행사명","example":"서면 겨울 축제","maxLength":200,"minLength":0},"content":{"type":"string","description":"요청 내용","example":"12월 서면 일대 겨울 축제 등재를 위해 계정을 신청합니다.","maxLength":2000,"minLength":0}},"required":["contactName","contactPhone","content","email","eventName","orgName"]},"PushTokenRequestDto":{"type":"object","description":"FCM 푸시 토큰 등록/갱신 요청 — 같은 토큰 재등록은 충돌 없이 현재 계정으로 갱신된다","properties":{"fcmToken":{"type":"string","description":"FCM 디바이스 토큰 (push_tokens PK, 최대 512자)","example":"fcm-token-abc123","maxLength":512,"minLength":0},"platform":{"type":"string","description":"플랫폼 — IOS·ANDROID·WEB (대소문자 무시)","example":"WEB","minLength":1},"appVersion":{"type":"string","description":"앱 버전 (선택, 최대 20자)","example":"1.0.0","maxLength":20,"minLength":0}},"required":["fcmToken","platform"]},"MissionVideoUploadRequestDto":{"type":"object","description":"미션 경유 영상 업로드 확정 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키. 같은 키로 다시 보내면 멱등하게 처리된다","example":"videos/pending/42/6f1c1f0e-1d2b-4a5a-9f0e-2b3c4d5e6f70.mp4","minLength":1},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각. 미래 시각은 거부되고(단말 시계 오차 5분 허용), 미션 기간 밖도 거부된다","example":"2026-10-06T12:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoMissionVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/MissionVideoUploadResponseDto"}},"required":["data","developCode","message"]},"MissionVideoUploadResponseDto":{"type":"object","description":"미션 경유 영상 업로드 확정 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"서버가 정한 그 미션의 대표 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 대표 격자를 처음 점령했는지 여부. 재시도 응답은 항상 false","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}},"completedMissions":{"type":"array","description":"이 업로드로 새로 발급된 스탬프 — 이미 받았거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/CompletedMissionResponseDto"}}},"required":["completedMissions","gridId","newBadges","occupied","processingStatus","videoId"]},"FriendRequestCreateRequestDto":{"type":"object","description":"친구 요청 생성 요청","properties":{"friendCode":{"type":"string","description":"상대의 고정 친구 코드","example":"AB3DE7GH","minLength":1}},"required":["friendCode"]},"ApiResponseDtoFriendRequestCreateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendRequestCreateResponseDto"}},"required":["data","developCode","message"]},"FriendRequestCreateResponseDto":{"type":"object","description":"친구 요청 생성 응답","properties":{"status":{"type":"string","description":"PENDING = 요청이 등록돼 상대 수락 대기, ACCEPTED = 상대가 먼저 보낸 요청이 있어 즉시 친구 성립(자동 수락 — FR-8). FE 는 이 값으로 \"요청 보냄\"과 \"친구가 됐어요\" 화면을 구분한다.","enum":["PENDING","ACCEPTED"]}},"required":["status"]},"EventVideoCommentRequestDto":{"type":"object","description":"행사 영상 댓글 작성·수정 요청","properties":{"content":{"type":"string","description":"댓글 본문 (1~500자)","example":"저도 어제 다녀왔어요","maxLength":500,"minLength":0}},"required":["content"]},"ApiResponseDtoEventVideoCommentResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoCommentResponseDto"}},"required":["data","developCode","message"]},"EventVideoCommentResponseDto":{"type":"object","description":"행사 영상 댓글","properties":{"commentId":{"type":"integer","format":"int64","description":"댓글 ID","example":3021},"authorId":{"type":"integer","format":"int64","description":"작성자 사용자 ID","example":7007},"authorNickname":{"type":"string","description":"작성자 닉네임","example":"필맵러"},"content":{"type":"string","description":"댓글 본문","example":"저도 어제 다녀왔어요"},"createdAt":{"type":"string","format":"date-time","description":"작성 시각","example":"2026-10-06T12:30:00Z"}},"required":["authorId","authorNickname","commentId","content","createdAt"]},"EventVideoUploadRequestDto":{"type":"object","description":"행사 영상 업로드 확정 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키. 같은 키로 다시 보내면 멱등하게 처리된다","example":"videos/pending/42/6f1c1f0e-1d2b-4a5a-9f0e-2b3c4d5e6f70.mp4","minLength":1},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각. 미래 시각은 거부된다(단말 시계 오차 5분 허용)","example":"2026-10-06T12:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoEventVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoUploadResponseDto"}},"required":["data","developCode","message"]},"EventVideoUploadResponseDto":{"type":"object","description":"행사 영상 업로드 확정 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"서버가 지정한 대표 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 대표 격자를 처음 점령했는지 여부. 재시도 응답은 항상 false","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}}},"required":["gridId","newBadges","occupied","processingStatus","videoId"]},"SignupRequestDto":{"type":"object","description":"이메일 회원가입 요청","properties":{"email":{"type":"string","format":"email","description":"이메일 (최대 255자, 중복 불가)","example":"user@fillmap.dev","maxLength":255,"minLength":0},"password":{"type":"string","description":"비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"},"nickname":{"type":"string","description":"닉네임 (2~20자)","example":"채우미","maxLength":20,"minLength":2}},"required":["email","nickname","password"]},"ApiResponseDtoSignupResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/SignupResponseDto"}},"required":["data","developCode","message"]},"SignupResponseDto":{"type":"object","description":"회원가입 성공 응답 — 생성된 사용자 정보","properties":{"id":{"type":"integer","format":"int64","description":"생성된 사용자 ID","example":1},"email":{"type":"string","description":"가입 이메일","example":"user@fillmap.dev"},"nickname":{"type":"string","description":"닉네임","example":"채우미"},"createdAt":{"type":"string","format":"date-time","description":"가입 시각","example":"2026-07-17T20:11:03Z"}},"required":["createdAt","email","id","nickname"]},"ReissueRequestDto":{"type":"object","description":"토큰 재발급 요청. 웹은 리프레시 토큰이 쿠키(refreshToken)로 전송되므로 body 를 생략할 수 있다.","properties":{"refreshToken":{"type":"string","description":"앱(X-Client-Type: app) 클라이언트의 리프레시 토큰. 웹은 쿠키를 사용하므로 생략.","example":"eyJhbGciOiJIUzI1NiJ9..."}}},"ApiResponseDtoReissueResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ReissueResponseDto"}},"required":["data","developCode","message"]},"ReissueResponseDto":{"type":"object","description":"토큰 재발급 성공 응답","properties":{"accessToken":{"type":"string","description":"새로 발급된 JWT 액세스 토큰.","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":["string","null"],"description":"회전된 새 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 재설정되므로 null 이다.","example":"eyJhbGciOiJIUzI1NiJ9..."}},"required":["accessToken","refreshToken"]},"PasswordResetConfirmRequestDto":{"type":"object","description":"비밀번호 재설정 확정 요청","properties":{"token":{"type":"string","description":"재설정 링크의 token 쿼리 값","example":"9pQ2f7Zk...","minLength":1},"newPassword":{"type":"string","description":"새 비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"}},"required":["newPassword","token"]},"PasswordResetRequestDto":{"type":"object","description":"비밀번호 재설정 링크 요청","properties":{"email":{"type":"string","format":"email","description":"계정 이메일(아이디)","example":"organizer@fillmap.dev","maxLength":255,"minLength":0}},"required":["email"]},"PasswordInitialRequestDto":{"type":"object","description":"초기 비밀번호 설정 요청","properties":{"newPassword":{"type":"string","description":"새 비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"}},"required":["newPassword"]},"PasswordChangeRequestDto":{"type":"object","description":"비밀번호 변경 요청","properties":{"currentPassword":{"type":"string","description":"현재 비밀번호. 초기 비밀번호 상태면 발급받은 그 값이다","example":"Initial1234","minLength":1},"newPassword":{"type":"string","description":"새 비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"}},"required":["currentPassword","newPassword"]},"OidcLoginRequestDto":{"type":"object","description":"소셜(OIDC) 로그인 요청","properties":{"idToken":{"type":"string","description":"소셜 제공자(카카오 등)에서 발급받은 OIDC ID Token","example":"eyJraWQiOiI...","minLength":1}},"required":["idToken"]},"ApiResponseDtoLoginResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/LoginResponseDto"}},"required":["data","developCode","message"]},"LoginResponseDto":{"type":"object","description":"로그인 성공 응답","properties":{"accessToken":{"type":"string","description":"발급된 JWT 액세스 토큰. 이후 요청 Authorization 헤더에 'Bearer {토큰}'으로 넣는다.","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":["string","null"],"description":"발급된 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 내려가므로 null 이다.","example":"eyJhbGciOiJIUzI1NiJ9..."},"role":{"type":"string","description":"로그인한 사용자의 역할. 화면이 일반 사용자·행사 운영자·관리자 진입을 가르는 재료다 (MSG-496).","enum":["USER","ORG","ADMIN"],"example":"USER"}},"required":["accessToken","refreshToken","role"]},"KakaoCodeLoginRequestDto":{"type":"object","description":"카카오 인가 코드 로그인 요청 (웹). 카카오 콜백으로 받은 코드를 서버가 ID Token 으로 교환한다.","properties":{"code":{"type":"string","description":"카카오 콜백 쿼리로 받은 1회용 인가 코드","example":"vBv8oXbeLnDF2mkw...","minLength":1},"redirectUri":{"type":"string","description":"인가 요청에 사용한 redirect URI 그대로. 카카오 콘솔 등록값과 정확히 일치해야 한다.","example":"http://localhost:5173/oauth/kakao/callback","minLength":1}},"required":["code","redirectUri"]},"LogoutRequestDto":{"type":"object","description":"로그아웃 요청 (선택 body) — fcmToken 이 있으면 세션 삭제와 함께 해당 FCM 푸시 토큰도 정리된다","properties":{"fcmToken":{"type":"string","description":"정리할 FCM 토큰 (선택)","example":"fcm-token-abc123"}}},"LoginRequestDto":{"type":"object","description":"이메일/비밀번호 로그인 요청","properties":{"email":{"type":"string","format":"email","description":"가입한 이메일","example":"user@fillmap.dev","minLength":1},"password":{"type":"string","description":"비밀번호 (영문+숫자 포함 8~64자)","example":"Fillmap1234","minLength":1}},"required":["email","password"]},"DevSocialLoginRequestDto":{"type":"object","description":"[로컬/dev 전용] 소셜 로그인 모의 요청 — 실제 소셜 ID Token 없이 (provider, oid)로 로그인/가입한다.","properties":{"provider":{"type":"string","description":"소셜 제공자 (기본 KAKAO)","example":"KAKAO"},"oid":{"type":"string","description":"소셜 고유 식별자(oid). 같은 값이면 같은 사용자로 재로그인된다.","example":"dev-kakao-1","minLength":1},"email":{"type":"string","description":"이메일 (선택). 없으면 {oid}@dev.local","example":"kakaouser@dev.local"},"nickname":{"type":"string","description":"닉네임 (선택). 없으면 dev-{oid}","example":"카카오테스터"}},"required":["oid"]},"AdminVideoUnblindResponseDto":{"type":"object","description":"블라인드 해제 결과 — 복구된 영상 상태.","properties":{"videoId":{"type":"integer","format":"int64","description":"해제된 영상 ID","example":1042},"status":{"type":"string","description":"해제 후 영상 상태 — 성공이면 항상 ACTIVE","enum":["ACTIVE","BLINDED","DELETED"],"example":"ACTIVE"}},"required":["status","videoId"]},"ApiResponseDtoAdminVideoUnblindResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminVideoUnblindResponseDto"}},"required":["data","developCode","message"]},"AdminReportProcessResponseDto":{"type":"object","description":"신고 승인·기각 처리 결과 — 종결된 신고 상태와 처리 후 영상 상태.","properties":{"reportId":{"type":"integer","format":"int64","description":"처리된 신고 ID","example":7},"status":{"type":"string","description":"처리 후 신고 상태 — 승인이면 RESOLVED, 기각이면 REJECTED","enum":["PENDING","REVIEWING","RESOLVED","REJECTED"],"example":"RESOLVED"},"videoId":{"type":"integer","format":"int64","description":"신고 대상 영상 ID","example":1042},"videoStatus":{"type":"string","description":"처리 후 영상 상태 — 승인의 전이 생략 케이스(FR-5)를 이 값으로 구분한다","enum":["ACTIVE","BLINDED","DELETED"],"example":"BLINDED"},"reviewedAt":{"type":"string","format":"date-time","description":"처리 시각","example":"2026-08-06T11:00:00Z"}},"required":["reportId","reviewedAt","status","videoId","videoStatus"]},"ApiResponseDtoAdminReportProcessResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReportProcessResponseDto"}},"required":["data","developCode","message"]},"OrgAccountCreateRequestDto":{"type":"object","description":"행사 운영자 계정 직접 발급 요청","properties":{"orgName":{"type":"string","description":"기관명","example":"부산진구청","maxLength":100,"minLength":0},"contactName":{"type":"string","description":"담당자 이름 (2~20자)","example":"김담당","maxLength":20,"minLength":2},"email":{"type":"string","format":"email","description":"공식 이메일. 계정 아이디이자 초기 비밀번호를 받을 주소다","example":"event@busanjin.go.kr","maxLength":255,"minLength":0},"contactPhone":{"type":"string","description":"담당자 연락처 (선택). 값이 있으면 숫자로 시작하고 끝나는 숫자·하이픈 9~20자","example":"010-1234-5678","pattern":"^[0-9][0-9-]{7,18}[0-9]$"}},"required":["contactName","email","orgName"]},"ApiResponseDtoOrgAccountIssueResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OrgAccountIssueResponseDto"}},"required":["data","developCode","message"]},"OrgAccountIssueResponseDto":{"type":"object","description":"계정 발급 결과","properties":{"userId":{"type":"integer","format":"int64","description":"발급된 계정 id","example":42},"emailSent":{"type":"boolean","description":"초기 비밀번호 메일 발송 성공 여부. true 는 SES 접수까지의 성공이고 배달 확인은 아니다","example":true}},"required":["emailSent","userId"]},"ApiResponseDtoOrgAccountResendResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OrgAccountResendResponseDto"}},"required":["data","developCode","message"]},"OrgAccountResendResponseDto":{"type":"object","description":"초기 비밀번호 재발송 결과","properties":{"emailSent":{"type":"boolean","description":"메일 발송 성공 여부. true 는 SES 접수까지의 성공이고 배달 확인은 아니다","example":true}},"required":["emailSent"]},"OrgAccountRequestRejectRequestDto":{"type":"object","description":"계정 발급 요청 반려 요청","properties":{"reason":{"type":"string","description":"반려 사유 (최대 500자). 관리자가 신청자에게 수기로 통보할 때 쓴다","example":"기관 확인 서류가 누락되었습니다","maxLength":500,"minLength":0},"updatedAt":{"type":"string","format":"date-time","description":"상세 조회로 받은 마지막 접수 시각. 값이 다르면 검토 이후 요청이 바뀐 것이라 반려가 거부된다"}},"required":["reason","updatedAt"]},"OrgAccountRequestApproveRequestDto":{"type":"object","description":"계정 발급 요청 승인 요청","properties":{"updatedAt":{"type":"string","format":"date-time","description":"상세 조회로 받은 마지막 접수 시각. 값이 다르면 검토 이후 요청이 바뀐 것이라 승인이 거부된다"}},"required":["updatedAt"]},"AdminEventUnpublishRequestDto":{"type":"object","description":"행사 노출 중지 요청","properties":{"reason":{"type":"string","description":"중지 사유 — 행사 운영자에게 그대로 발송된다","example":"행사가 취소되어 노출을 중지합니다","minLength":1}},"required":["reason"]},"AdminEventUnpublishResponseDto":{"type":"object","description":"행사 노출 중지 결과","properties":{"submissionId":{"type":"integer","format":"int64","description":"중지한 승인 행사 식별자 (= 신청 id)","example":7},"unpublishedAt":{"type":"string","format":"date-time","description":"중지 시각 (UTC)","example":"2026-08-30T02:11:00Z"},"emailSent":{"type":"boolean","description":"사유 통지 메일 발송 성공 여부 — false 여도 중지는 유지된다","example":true}},"required":["emailSent","submissionId","unpublishedAt"]},"ApiResponseDtoAdminEventUnpublishResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminEventUnpublishResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionRejectRequestDto":{"type":"object","description":"행사 등재 신청 반려 요청","properties":{"reasonCodes":{"type":"array","description":"반려 항목 코드 1개 이상 (PERIOD, AREA, IMAGE, INFO — 중복 불가)","example":["AREA","INFO"],"items":{"type":"string"}},"reasonText":{"type":"string","description":"반려 사유 본문","example":"신청 영역이 행사 실제 범위보다 넓습니다","minLength":1}},"required":["reasonCodes","reasonText"]},"ApiResponseDtoEventSubmissionApproveResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventSubmissionApproveResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionApproveResponseDto":{"type":"object","description":"행사 등재 신청 승인 결과","properties":{"submissionId":{"type":"integer","format":"int64","description":"승인한 신청 id","example":7},"approvalNo":{"type":"string","description":"부여된 승인 번호","example":"APR-2026-0001"},"status":{"type":"string","description":"전이 후 상태","example":"APPROVED"}},"required":["approvalNo","status","submissionId"]},"EmailChangeRejectRequestDto":{"type":"object","description":"아이디 변경 요청 반려","properties":{"requestedAt":{"type":"string","format":"date-time","description":"검토한 요청의 접수 시각 (목록의 createdAt 을 그대로)","example":"2026-08-28T02:00:00Z"},"reason":{"type":"string","description":"반려 사유","example":"기관 도메인이 아닌 이메일이라 반려합니다","minLength":1}},"required":["reason","requestedAt"]},"EmailChangeApproveRequestDto":{"type":"object","description":"아이디 변경 요청 승인","properties":{"requestedAt":{"type":"string","format":"date-time","description":"검토한 요청의 접수 시각 (목록의 createdAt 을 그대로)","example":"2026-08-28T02:00:00Z"}},"required":["requestedAt"]},"ApiResponseDtoEmailChangeApproveResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EmailChangeApproveResponseDto"}},"required":["data","developCode","message"]},"EmailChangeApproveResponseDto":{"type":"object","description":"아이디 변경 승인 결과","properties":{"requestId":{"type":"integer","format":"int64","description":"승인한 요청 id","example":3},"email":{"type":"string","description":"교체된 새 아이디(로그인 이메일)","example":"festival@busanjin.go.kr"},"emailSent":{"type":"boolean","description":"새 이메일로 보낸 통지 성공 여부 — false 여도 교체는 유지된다","example":true}},"required":["email","emailSent","requestId"]},"VideoVisibilityRequestDto":{"type":"object","description":"영상 공개 범위 전환 요청. PUBLIC · PRIVATE · FRIENDS.","properties":{"visibility":{"type":"string","description":"공개 범위. PUBLIC, PRIVATE, FRIENDS 중 하나 (대소문자 무관)","example":"PUBLIC","minLength":1}},"required":["visibility"]},"ApiResponseDtoVideoVisibilityResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoVisibilityResponseDto"}},"required":["data","developCode","message"]},"VideoVisibilityResponseDto":{"type":"object","description":"영상 공개 범위 전환 응답. 전환 후 공개 범위를 담는다.","properties":{"videoId":{"type":"integer","format":"int64","description":"전환된 영상 ID","example":1042},"visibility":{"type":"string","description":"전환 후 공개 범위 (PUBLIC, PRIVATE, FRIENDS 중 하나)","example":"PUBLIC"}},"required":["videoId","visibility"]},"OrgProfileUpdateRequestDto":{"type":"object","description":"담당자 정보 수정 요청","properties":{"contactName":{"type":"string","description":"담당자 이름 (2~20자). users.nickname 에 저장되므로 가입 닉네임과 같은 제약이다","example":"김담당","maxLength":20,"minLength":2},"contactPhone":{"type":"string","description":"담당자 연락처. 숫자로 시작하고 끝나는 숫자·하이픈 9~20자","example":"010-1234-5678","minLength":1,"pattern":"^[0-9][0-9-]{7,18}[0-9]$"}},"required":["contactName","contactPhone"]},"ApiResponseDtoOrgProfileResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OrgProfileResponseDto"}},"required":["data","developCode","message"]},"OrgProfileResponseDto":{"type":"object","description":"행사 운영자 계정 설정 응답","properties":{"email":{"type":"string","description":"아이디(공식 이메일). 읽기 전용","example":"organizer@fillmap.dev"},"contactName":{"type":"string","description":"담당자 이름","example":"김담당"},"contactPhone":{"type":["string","null"],"description":"담당자 연락처. 아직 입력한 적이 없으면 null","example":"010-1234-5678"}},"required":["contactName","contactPhone","email"]},"EventSubmissionUpdateRequestDto":{"type":"object","description":"반려본 수정 재제출 요청 — 유형을 뺀 전체 교체","properties":{"title":{"type":"string","description":"축제명 / 팝업명","example":"부산불꽃축제","maxLength":100,"minLength":0},"organizerName":{"type":"string","description":"주최 기관 / 브랜드·운영사","example":"부산문화관광축제조직위원회","maxLength":100,"minLength":0},"startsOn":{"type":"string","format":"date","description":"행사 시작일 (KST 날짜)","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일 (KST 날짜). 오늘 이전이면 13433","example":"2026-11-07"},"operatingHours":{"type":"string","description":"운영 시간 — POPUP 전용 필수","example":"11:00 ~ 20:00","maxLength":100,"minLength":0},"programDescription":{"type":"string","description":"주요 프로그램 — FESTIVAL 전용 필수","example":"멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영","maxLength":2000,"minLength":10},"participationMethod":{"type":"string","description":"참여 방식 — EVENT 전용 필수. 부모 이벤트는 재제출로 바꿀 수 없어 이 요청에 필드가 없다","example":"부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다","maxLength":2000,"minLength":10},"description":{"type":"string","description":"행사 소개","example":"광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제","maxLength":2000,"minLength":10},"imageS3Key":{"type":"string","description":"대표 이미지의 pending S3 키. 생략하거나 null 이면 기존 이미지를 유지한다.","example":"event-submissions/pending/12/3f0c1f2e-....jpg"},"locations":{"type":"array","description":"행사 위치 목록. 통째로 갈아끼우고 대표 격자를 전부 재계산한다.","items":{"$ref":"#/components/schemas/EventSubmissionLocationRequestDto"}}},"required":["description","endsOn","organizerName","startsOn","title"]},"NotificationPreferenceUpdateRequestDto":{"type":"object","description":"카테고리 수신 토글 요청 — 같은 값 재전환은 멱등하게 성공한다","properties":{"enabled":{"type":"boolean","description":"수신 여부 — false 면 off(거부 행 저장), true 면 on(행 삭제)","example":false}},"required":["enabled"]},"ApiResponseDtoNotificationPreferenceResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationPreferenceResponseDto"}},"required":["data","developCode","message"]},"CategoryPreferenceDto":{"type":"object","description":"카테고리 하나의 수신 상태","properties":{"category":{"type":"string","description":"알림 카테고리","enum":["BADGE","HOTZONE","REMIND","VIDEO","WEEKLY","FRIEND","MISSION_NEARBY","EVENT"],"example":"HOTZONE"},"enabled":{"type":"boolean","description":"수신 여부 — off 행 부재면 true (opt-out 기본 전부 on)","example":true}},"required":["category","enabled"]},"NotificationPreferenceResponseDto":{"type":"object","description":"알림 설정 — 전 카테고리(8종)의 수신 상태 (저장 행 없는 카테고리는 true)","properties":{"preferences":{"type":"array","description":"카테고리별 수신 상태 (BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT 고정 8종 — MODERATION 은 설정 대상이 아니라 없다)","items":{"$ref":"#/components/schemas/CategoryPreferenceDto"}}},"required":["preferences"]},"ApiResponseDtoListZoneResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ZoneResponseDto"}}},"required":["data","developCode","message"]},"ZoneResponseDto":{"type":"object","description":"구역(zone)의 이름과 격자 사각형 범위 — 검색바 구역 이동·범위 오버레이용. 격자 표시명은 서버가 계산해 격자 응답에 함께 싣는다.","properties":{"zoneKey":{"type":"string","description":"안정 식별자 slug (zones.zone_key) — 클라이언트 참조·타이브레이크 기준","example":"seomyeon"},"name":{"type":"string","description":"구역명 (zones.name)","example":"서면"},"regionCode":{"type":["string","null"],"description":"소속 행정동 코드 (zones.region_code, nullable)","example":"2623051000"},"minGridY":{"type":"integer","format":"int32","description":"사각형 남단 행 (zones.min_grid_y)","example":16850},"maxGridY":{"type":"integer","format":"int32","description":"사각형 북단 행 = A행 (zones.max_grid_y)","example":16866},"minGridX":{"type":"integer","format":"int32","description":"사각형 서단 열 = 1열 (zones.min_grid_x)","example":11414},"maxGridX":{"type":"integer","format":"int32","description":"사각형 동단 열 (zones.max_grid_x)","example":11424},"priority":{"type":"integer","format":"int32","description":"겹침 결정성 우선순위 (zones.priority)","example":0}},"required":["maxGridX","maxGridY","minGridX","minGridY","name","priority","regionCode","zoneKey"]},"ApiResponseDtoVideoPlaybackResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoPlaybackResponseDto"}},"required":["data","developCode","message"]},"VideoPlaybackResponseDto":{"type":"object","description":"단건 영상 재생 조회 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID","example":1042},"playbackUrl":{"type":["string","null"],"description":"재생본 presigned GET URL. READY 아님·BLINDED(소유자)면 null"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. 썸네일 key 없음(READY 이전)이면 null"},"gridId":{"type":"string","description":"이 영상이 속한 격자 ID","example":"19422_9582"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"visibility":{"type":"string","description":"공개 범위 (PUBLIC, PRIVATE, FRIENDS 중 하나)","example":"PUBLIC"},"status":{"type":"string","description":"영상 상태 (ACTIVE/BLINDED). 소유자가 블라인드 사유를 구분하는 축","example":"ACTIVE"},"viewCount":{"type":"integer","format":"int64","description":"조회수 (이번 조회 증가 전 스냅샷)","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용)","example":"2026-07-20T18:03:11Z"},"expiresInSec":{"type":["integer","null"],"format":"int64","description":"playbackUrl presign TTL(초). playbackUrl=null 이면 null"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속(해상 등)이거나 미판정이면 null","example":"서울특별시 강남구 역삼1동"},"highlights":{"type":["array","null"],"description":"AI 추천 하이라이트 구간 [[시작초, 끝초], ...]. 최대 3구간, 초는 소수점 둘째 자리. 배열 순서가 추천 우선순위(첫 요소가 최우선 추천). 없으면 null (READY 이전·FAILED·0구간 포함, 빈 배열은 내려가지 않는다) 예시: [[0.0, 4.25], [12.0, 18.5], [20.0, 27.5]]","items":{"type":"array","items":{"type":"number","format":"double"}}},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"}},"required":["durationSec","expiresInSec","gridId","highlights","nickname","playbackUrl","processingStatus","recordedAt","regionName","status","thumbnailUrl","videoId","viewCount","visibility","zoneCell","zoneName"]},"ApiResponseDtoListTrendingKeywordResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/TrendingKeywordResponseDto"}}},"required":["data","developCode","message"]},"TrendingKeywordResponseDto":{"type":"object","description":"인기 검색어 1건. 클릭 시 keyword 로 기존 장소 검색 API 를 다시 호출한다.","properties":{"rank":{"type":"integer","format":"int32","description":"순위 (1부터)","example":1},"keyword":{"type":"string","description":"정규화된 검색어","example":"홍대 카페"}},"required":["keyword","rank"]},"ApiResponseDtoListPlaceSearchResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/PlaceSearchResponseDto"}}},"required":["data","developCode","message"]},"PlaceSearchResponseDto":{"type":"object","description":"장소 검색 결과 1건. 선택 시 lat/lng 로 지도 이동 + gridId 로 격자 하이라이트를 한 번에 처리한다.","properties":{"name":{"type":"string","description":"장소명 (카카오 place_name)","example":"부산대학교"},"address":{"type":"string","description":"표시용 주소 — 도로명 우선, 없으면 지번 (§D2)","example":"부산 금정구 부산대학로63번길 2"},"lat":{"type":"number","format":"double","description":"위도 (WGS84, 카카오 y 직결 — 변환 없음)","example":35.23272},"lng":{"type":"number","format":"double","description":"경도 (WGS84, 카카오 x)","example":129.08246},"gridId":{"type":"string","description":"그 좌표의 격자 ID — FE 격자 하이라이트 키 (즉석 계산, 저장 아님)","example":"16941_11439"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖이면 null — 표시 라벨은 address 가 맡으므로 행정동 폴백 재료를 싣지 않는다(§D2 유지).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null 이다.","example":"I-6"}},"required":["address","gridId","lat","lng","name","zoneCell","zoneName"]},"ApiResponseDtoRegionExploreResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionExploreResponseDto"}},"required":["data","developCode","message"]},"ExploreGridResponseDto":{"type":"object","description":"전역 탐색 격자 카드","properties":{"gridId":{"type":"string","description":"격자 ID — 카드 탭 시 격자 전역 영상 목록(MSG-237) 진입 키","example":"16676_11596"},"gridY":{"type":"integer","format":"int64","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다). FE 지도 이동·라벨 조합","example":16676},"gridX":{"type":"integer","format":"int64","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":11596},"videoCount":{"type":"integer","format":"int32","description":"그 격자의 게이트 통과 영상 수 — \"N개 영상\"","example":138},"coverThumbnailUrl":{"type":["string","null"],"description":"커버 썸네일 presigned GET URL. READY 게이트라 non-null 기대(null 이면 null 통과)"},"coverDurationSec":{"type":"integer","format":"int32","description":"커버 영상 길이(초) — duration 뱃지","example":12},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 FE 는 래퍼의 regionName 을 라벨로 쓴다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["coverDurationSec","coverThumbnailUrl","gridId","gridX","gridY","videoCount","zoneCell","zoneName"]},"RegionExploreResponseDto":{"type":"object","description":"행정동 격자 카드 리스트 + 헤더 카운트","properties":{"regionCode":{"type":"string","description":"행정동 코드 (요청 에코)","example":"2644056000"},"regionName":{"type":["string","null"],"description":"행정동 이름 — 미존재 코드면 null","example":"부산광역시 부산진구 부전2동"},"gridCount":{"type":"integer","format":"int32","description":"게이트 통과 영상 ≥1 격자 수 — \"이 지역 격자 N개\"","example":5},"videoCount":{"type":"integer","format":"int64","description":"게이트 통과 영상 총수 — \"영상 M개\"","example":355},"grids":{"type":"array","description":"격자 카드 (정렬·limit 적용 후). 없으면 빈 배열","items":{"$ref":"#/components/schemas/ExploreGridResponseDto"}}},"required":["gridCount","grids","regionCode","regionName","videoCount"]},"ApiResponseDtoListRegionStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionStatResponseDto"}}},"required":["data","developCode","message"]},"RegionStatResponseDto":{"type":"object","description":"한 행정동의 수집률. 사용자가 그 행정동에서 점령(수집)한 격자 수와 진행률.","properties":{"regionCode":{"type":"string","description":"행정동 코드 (region_stats.region_code)","example":"1168051500"},"regionName":{"type":"string","description":"행정동 이름 (regions.region_name)","example":"서울특별시 강남구 역삼1동"},"parentCode":{"type":["string","null"],"description":"상위 시군구 코드 (regions.parent_code) — NULL 허용 컬럼이라 최상위 행은 null","example":"11680"},"collectedCount":{"type":"integer","format":"int32","description":"점령(수집)한 격자 수","example":5},"totalCount":{"type":"integer","format":"int32","description":"그 행정동 전체 격자 수(분모)","example":20},"progressRate":{"type":"number","description":"수집률(%) — 100 상한 clamp","example":25.0},"updatedAt":{"type":"string","format":"date-time","description":"수집률 캐시 기준 시각","example":"2026-07-20T10:00:00Z"}},"required":["collectedCount","parentCode","progressRate","regionCode","regionName","totalCount","updatedAt"]},"ApiResponseDtoRegionNationalStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionNationalStatResponseDto"}},"required":["data","developCode","message"]},"RegionNationalStatResponseDto":{"type":"object","description":"내 전국 탐험률 재료. 점령한 격자 수(분자)와 전국 격자 총수(분모)의 원값.","properties":{"collectedCount":{"type":"integer","format":"int64","description":"내가 점령(수집)한 격자 수의 전국 합. 수집이 없으면 0","example":1223},"totalCount":{"type":"integer","format":"int64","description":"전국 격자 총수(분모). 0 이면 기준 데이터 미적재 상태라 화면은 비율을 그리지 않는다","example":10193482}},"required":["collectedCount","totalCount"]},"ApiResponseDtoRegionStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/RegionStatResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"ApiResponseDtoRegionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/RegionResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"RegionResponseDto":{"type":"object","description":"좌표를 포함하는 행정동. 포함 행정동이 없으면(바다·국외) data 가 null 이다.","properties":{"regionCode":{"type":"string","description":"행정동 코드 (regions.region_code = adm_cd2)","example":"1168051500"},"regionName":{"type":"string","description":"행정동 이름 (regions.region_name = adm_nm)","example":"서울특별시 강남구 역삼1동"},"parentCode":{"type":["string","null"],"description":"상위 시군구 코드 (regions.parent_code) — NULL 허용 컬럼이라 최상위 행은 null","example":"11680"}},"required":["parentCode","regionCode","regionName"]},"ApiResponseDtoRegionExplorePageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionExplorePageResponseDto"}},"required":["data","developCode","message"]},"RegionExplorePageResponseDto":{"type":"object","description":"전체 지역 개인화 커서 페이지","properties":{"items":{"type":"array","description":"현재 페이지 행정동 목록. 최대 20개","items":{"$ref":"#/components/schemas/RegionGridCountResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부"},"nextCursor":{"type":["string","null"],"description":"다음 요청에 그대로 전달할 불투명 커서"}},"required":["hasNext","items","nextCursor"]},"RegionGridCountResponseDto":{"type":"object","description":"전체 지역 리스트 항목 (행정동별 격자 수)","properties":{"regionCode":{"type":"string","description":"행정동 코드 — 선택 시 격자 카드 조회에 전달","example":"2644056000"},"regionName":{"type":"string","description":"행정동 이름","example":"부산광역시 부산진구 부전2동"},"gridCount":{"type":"integer","format":"int32","description":"그 행정동의 게이트 통과 격자 수","example":5}},"required":["gridCount","regionCode","regionName"]},"ApiResponseDtoListRegionDistrictResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionDistrictResponseDto"}}},"required":["data","developCode","message"]},"RegionDistrictResponseDto":{"type":"object","description":"시군구 한 건. 이름·식별자·전체 격자 수.","properties":{"parentCode":{"type":"string","description":"시군구 식별자(행정동 코드 앞 5자리). /api/regions/stats 의 parentCode 로 그대로 쓴다","example":"11680"},"name":{"type":"string","description":"시군구 이름","example":"강남구"},"gridCount":{"type":"integer","format":"int64","description":"그 시군구의 전체 격자 수(사용자 무관). 0 인 시군구는 목록에 없다","example":4102}},"required":["gridCount","name","parentCode"]},"ApiResponseDtoOrgEventListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OrgEventListResponseDto"}},"required":["data","developCode","message"]},"OrgEventCityCountResponseDto":{"type":"object","description":"시·도별 승인 이벤트 건수 — 모달 시·도 칩 재료","properties":{"cityName":{"type":"string","description":"시·도 이름 — city 필터에 그대로 넣는 값","example":"부산"},"count":{"type":"integer","format":"int32","description":"그 시·도의 승인 이벤트 수 (전체 기준)","example":3}},"required":["cityName","count"]},"OrgEventItemResponseDto":{"type":"object","description":"승인 이벤트 하나 — 참여 신청(MSG-502)이 부모로 지정할 후보","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id — 참여 신청의 부모 참조값","example":1},"name":{"type":"string","description":"이벤트 이름 (회차 제목)","example":"부산국제영화제"},"cityName":{"type":"string","description":"대상 지역 시·도 — 시·도 칩 묶음 기준","example":"부산"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2026-10-06T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2026-10-15T13:00:00Z"},"placeLabel":{"type":["string","null"],"description":"장소 라벨 — 표시 순서가 가장 앞선 위치의 이름. 위치가 없는 회차면 null","example":"영화의전당"}},"required":["cityName","endsAt","name","occurrenceId","placeLabel","startsAt"]},"OrgEventListResponseDto":{"type":"object","description":"승인 이벤트 목록 — 참여 신청 모달 재료","properties":{"totalCount":{"type":"integer","format":"int32","description":"승인 이벤트 전체 건수 — 필터·검색과 무관한 '전체 보기' 칩 재료","example":4},"cityCounts":{"type":"array","description":"시·도별 건수 — 건수 내림차순, 동수는 이름 오름차순","items":{"$ref":"#/components/schemas/OrgEventCityCountResponseDto"}},"events":{"type":"array","description":"필터·검색이 적용된 목록 — 시작일 오름차순, 동시각은 회차 id 오름차순","items":{"$ref":"#/components/schemas/OrgEventItemResponseDto"}}},"required":["cityCounts","events","totalCount"]},"ApiResponseDtoEventSubmissionDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventSubmissionDetailResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionDetailResponseDto":{"type":"object","description":"신청 상세","properties":{"id":{"type":"integer","format":"int64","description":"신청 id","example":7},"submissionNo":{"type":"string","description":"신청 번호","example":"FM-2026-0007"},"type":{"type":"string","description":"등록 유형","example":"FESTIVAL"},"status":{"type":"string","description":"신청 상태","example":"REJECTED"},"title":{"type":"string","description":"축제명 / 팝업명"},"organizerName":{"type":"string","description":"주최 기관 / 브랜드·운영사"},"startsOn":{"type":"string","format":"date","description":"행사 시작일","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일","example":"2026-11-07"},"operatingHours":{"type":["string","null"],"description":"운영 시간 — POPUP 만 값이 있다"},"programDescription":{"type":["string","null"],"description":"주요 프로그램 — FESTIVAL 만 값이 있다"},"participationMethod":{"type":["string","null"],"description":"참여 방식 — EVENT 만 값이 있다"},"parentEvent":{"anyOf":[{"$ref":"#/components/schemas/EventSubmissionParentEventResponseDto"},{"type":"null"}],"description":"참여할 부모 이벤트 — EVENT 만 값이 있다"},"description":{"type":"string","description":"행사 소개"},"imageUrl":{"type":"string","description":"대표 이미지 열람용 presigned GET URL"},"locations":{"type":"array","description":"위치 목록 — 순번 오름차순","items":{"$ref":"#/components/schemas/EventSubmissionLocationResponseDto"}},"rejection":{"anyOf":[{"$ref":"#/components/schemas/EventSubmissionRejectionResponseDto"},{"type":"null"}],"description":"현재 반려 사유 — 상태가 REJECTED 일 때만 값이 있다"},"history":{"type":"array","description":"상태 이력 — 발생 순","items":{"$ref":"#/components/schemas/EventSubmissionHistoryResponseDto"}},"updatedAt":{"type":"string","format":"date-time","description":"마지막 변경 시각 (UTC)","example":"2026-08-28T02:11:00Z"}},"required":["description","endsOn","history","id","imageUrl","locations","operatingHours","organizerName","parentEvent","participationMethod","programDescription","rejection","startsOn","status","submissionNo","title","type","updatedAt"]},"EventSubmissionHistoryResponseDto":{"type":"object","description":"신청 상태 이력 항목","properties":{"status":{"type":"string","description":"전이 후 상태","example":"REJECTED"},"reasonCodes":{"type":["array","null"],"description":"반려 항목 코드 — 반려 행에만 있고 그 외에는 null","items":{"type":"string"}},"reasonText":{"type":["string","null"],"description":"반려 사유 본문 — 반려 행에만 있고 그 외에는 null"},"changedAt":{"type":"string","format":"date-time","description":"전이 시각 (UTC)","example":"2026-08-28T02:00:00Z"}},"required":["changedAt","reasonCodes","reasonText","status"]},"EventSubmissionLocationResponseDto":{"type":"object","description":"신청 위치 상세","properties":{"order":{"type":"integer","format":"int32","description":"위치 순번 — 제출 배열 순서대로 1부터","example":1},"representativeGridId":{"type":"string","description":"서버가 계산한 대표 격자 id","example":"16860_11512"},"zoneName":{"type":["string","null"],"description":"구역 표시명 — 구역 밖이면 null","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 안 칸 이름 — 구역 밖이면 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"행정동 이름 — 무귀속이면 null","example":"부산 수영구 광안동"},"cellCount":{"type":"integer","format":"int32","description":"영역 합집합 칸 수 — 최대 81","example":21},"areaRects":{"type":"array","description":"제출 원본 사각형 — 재제출 폼 프리필 재료라 보낸 그대로다","items":{"$ref":"#/components/schemas/EventSubmissionAreaRectDto"}}},"required":["areaRects","cellCount","order","regionName","representativeGridId","zoneCell","zoneName"]},"EventSubmissionParentEventResponseDto":{"type":"object","description":"참여할 부모 이벤트 회차","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"회차 id","example":1},"name":{"type":"string","description":"이벤트 이름","example":"부산국제영화제"}},"required":["name","occurrenceId"]},"EventSubmissionRejectionResponseDto":{"type":"object","description":"반려 항목과 사유","properties":{"reasonCodes":{"type":"array","description":"반려 항목 코드 — PERIOD, AREA, IMAGE, INFO","example":["AREA","INFO"],"items":{"type":"string"}},"reasonText":{"type":"string","description":"반려 사유 본문"}},"required":["reasonCodes","reasonText"]},"ApiResponseDtoEventSubmissionMyListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventSubmissionMyListResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionMyListResponseDto":{"type":"object","description":"내 신청 목록과 상태별 건수","properties":{"counts":{"$ref":"#/components/schemas/EventSubmissionStatusCountsResponseDto","description":"상태별 건수 — 내 신청 전체 기준"},"submissions":{"type":"array","description":"신청 목록 — 최신 제출 순","items":{"$ref":"#/components/schemas/EventSubmissionSummaryResponseDto"}}},"required":["counts","submissions"]},"EventSubmissionStatusCountsResponseDto":{"type":"object","description":"내 신청의 상태별 건수","properties":{"inReview":{"type":"integer","format":"int64","description":"심사 중 건수","example":2},"approved":{"type":"integer","format":"int64","description":"승인 건수","example":1},"rejected":{"type":"integer","format":"int64","description":"반려 건수","example":1}},"required":["approved","inReview","rejected"]},"EventSubmissionSummaryResponseDto":{"type":"object","description":"내 신청 목록 항목","properties":{"id":{"type":"integer","format":"int64","description":"신청 id","example":7},"submissionNo":{"type":"string","description":"신청 번호","example":"FM-2026-0007"},"type":{"type":"string","description":"등록 유형","example":"FESTIVAL"},"title":{"type":"string","description":"축제명 / 팝업명","example":"부산불꽃축제"},"status":{"type":"string","description":"신청 상태","example":"REJECTED"},"startsOn":{"type":"string","format":"date","description":"행사 시작일","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일","example":"2026-11-07"},"updatedAt":{"type":"string","format":"date-time","description":"마지막 변경 시각 (UTC)","example":"2026-08-28T02:11:00Z"}},"required":["endsOn","id","startsOn","status","submissionNo","title","type","updatedAt"]},"ApiResponseDtoNotificationPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationPageResponseDto"}},"required":["data","developCode","message"]},"NotificationItemResponseDto":{"type":"object","description":"알림 한 건","properties":{"notificationId":{"type":"integer","format":"int64","description":"알림 ID — 읽음 처리와 커서에 쓴다","example":123},"category":{"type":"string","description":"알림 카테고리","enum":["BADGE","HOTZONE","REMIND","VIDEO","WEEKLY","FRIEND","MODERATION","EVENT"],"example":"BADGE"},"title":{"type":"string","description":"알림 제목","example":"새 뱃지 획득"},"body":{"type":"string","description":"알림 본문","example":"'첫 걸음' 뱃지를 획득했어요"},"createdAt":{"type":"string","format":"date-time","description":"생성 시각 (UTC)","example":"2026-08-19T02:11:00Z"},"read":{"type":"boolean","description":"읽음 여부","example":false}},"required":["body","category","createdAt","notificationId","read","title"]},"NotificationPageResponseDto":{"type":"object","description":"알림함 목록 한 페이지 — 최신순(id 내림차순)","properties":{"notifications":{"type":"array","description":"알림 항목 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/NotificationItemResponseDto"}},"nextCursor":{"type":["integer","null"],"format":"int64","description":"다음 페이지 커서 — 다음 요청의 cursor 로 그대로 되돌려 준다. hasNext 가 false 면 null","example":123},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부","example":true}},"required":["hasNext","nextCursor","notifications"]},"ApiResponseDtoNotificationUnreadCountResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationUnreadCountResponseDto"}},"required":["data","developCode","message"]},"NotificationUnreadCountResponseDto":{"type":"object","description":"안읽은 알림 개수 — 목록과 같은 노출 조건(최근 30일·수신 거부 스킵 제외)","properties":{"count":{"type":"integer","format":"int64","description":"안읽은 알림 개수 — 없으면 0","example":3}},"required":["count"]},"ApiResponseDtoMissionDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/MissionDetailResponseDto"}},"required":["data","developCode","message"]},"BoxShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"polygon":{"type":"array","items":{"$ref":"#/components/schemas/LatLng"}}}}],"description":"축제·팝업(EVENT·POPUP) — 격자 집합을 감싸는 경계 사각형","required":["polygon"]},"Cell":{"type":"object","description":"격자 중심점","properties":{"gridId":{"type":"string"},"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"}},"required":["gridId","lat","lng"]},"CellsShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"cells":{"type":"array","items":{"$ref":"#/components/schemas/Cell"}}}}],"description":"테마·지속(THEME·CONTINUOUS) — 각 격자 중심점","required":["cells"]},"LatLng":{"type":"object","description":"좌표 한 점","properties":{"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"}},"required":["lat","lng"]},"MissionDetailResponseDto":{"type":"object","description":"미션 상세 — 미션 정보 + 내 진행도 + 전체 영상 개수 + 코스 스팟별 통계","properties":{"mission":{"$ref":"#/components/schemas/MissionResponseDto","description":"미션 정보 — 목록(GET /api/missions/active)과 같은 필드·shape"},"progress":{"anyOf":[{"$ref":"#/components/schemas/MissionProgressResponseDto"},{"type":"null"}],"description":"내 진행도 — 목록 진행도(GET /api/missions/progress)와 같은 계산 (MSG-398 D8). 비로그인 조회면 키는 그대로 있고 값이 null 이다 (MSG-454)"},"videoCount":{"type":"integer","format":"int64","description":"미션 기간 안에 촬영된 전역 공개(ACTIVE·PUBLIC·READY) 영상 수 — 미션 영상 목록(MSG-390)의 실제 후보 수와 같다","example":19},"spotStats":{"type":"array","description":"코스 포토스팟별 방문 여부·영상 개수 — shape.spots 와 같은 순서(seq ASC NULLS LAST, gridId ASC). 코스가 아니면 빈 배열","items":{"$ref":"#/components/schemas/SpotStats"}}},"required":["mission","progress","spotStats","videoCount"]},"MissionProgressResponseDto":{"type":"object","description":"미션 하나에 대한 내 진행도","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 id (missions.id)","example":412},"targetCount":{"type":"integer","format":"int32","description":"완료에 필요한 격자 수 (missions.target_count)","example":1},"filledCount":{"type":"integer","format":"int32","description":"그 미션 격자 중 기간 안에 촬영한 내 영상이 있는 칸 수. targetCount 를 넘지 않는다","example":1},"completed":{"type":"boolean","description":"내 스탬프 보유 여부 (user_missions)","example":true}},"required":["completed","filledCount","missionId","targetCount"]},"MissionResponseDto":{"type":"object","description":"미션 하나 — 공통 필드 + 유형별 렌더 shape","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 id (missions.id)","example":12},"type":{"type":"string","description":"미션 유형 — FE 렌더러 판별자","enum":["COURSE","AREA","EVENT","THEME","CONTINUOUS","POPUP"],"example":"COURSE"},"title":{"type":"string","description":"미션 제목","example":"남파랑길 3코스"},"targetCount":{"type":"integer","format":"int32","description":"완료에 필요한 distinct 방문 격자 수(표시·판정 힌트, 판정은 MSG-223)","example":3},"startAt":{"type":["string","null"],"format":"date-time","description":"시작 시각. NULL = 무기간(상시)","example":"2026-11-01T00:00:00Z"},"endAt":{"type":["string","null"],"format":"date-time","description":"종료 시각. NULL = 무기간(상시)","example":"2026-11-01T23:59:59Z"},"shape":{"description":"유형별 렌더 shape 하나(type 에 대응하는 PATH/BOX/CELLS/REGION)","oneOf":[{"$ref":"#/components/schemas/BoxShape"},{"$ref":"#/components/schemas/CellsShape"},{"$ref":"#/components/schemas/PathShape"},{"$ref":"#/components/schemas/RegionShape"}]},"description":{"type":["string","null"],"description":"소개문 원문. 출처 표기 없이 그대로 노출한다","example":"부산 앞바다를 따라 걷는 해안 산책로"},"placeName":{"type":["string","null"],"description":"사람이 읽는 위치 한 줄 — 축제는 행사장, 팝업은 주소, 코스는 시군","example":"부산 영도구"},"sourceUrl":{"type":["string","null"],"description":"원문 링크 — 축제 홈페이지·팝업 상세 페이지. 코스는 없다","example":"https://festival.example.kr"},"operationTime":{"type":["string","null"],"description":"운영시간 안내 문구. 여러 줄이면 개행으로 이어 붙인다(팝업 전용)","example":"매일 11:00 ~ 20:00"},"imageUrl":{"type":["string","null"],"description":"대표 이미지 주소 — 우리 스토리지 URL 만 들어간다(MSG-383 §D7)","example":"https://cdn.fillmap.kr/mission/12.webp"},"distanceMeters":{"type":["integer","null"],"format":"int32","description":"코스 총 거리(미터). 코스가 아니면 없다","example":14000},"durationMinutes":{"type":["integer","null"],"format":"int32","description":"코스 소요시간(분). 코스가 아니면 없다","example":330},"difficulty":{"type":["integer","null"],"format":"int32","description":"코스 난이도 — 두루누비 등급 1(쉬움)·2(보통)·3(어려움). 코스가 아니면 없다","example":2}},"required":["description","difficulty","distanceMeters","durationMinutes","endAt","imageUrl","missionId","operationTime","placeName","shape","sourceUrl","startAt","targetCount","title","type"]},"MissionShape":{"description":"미션 유형별 렌더 shape (상위 type 으로 판별). PATH·BOX·CELLS·REGION 중 하나."},"PathShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"line":{"type":["object","null"],"description":"코스 라인 GeoJSON LineString 원문 — missions.path 는 NULL 허용 컬럼이라 없을 수 있다"},"spots":{"type":"array","items":{"$ref":"#/components/schemas/Spot"}}}}],"description":"코스(COURSE) — GeoJSON LineString + seq순 포토스팟 마커","required":["line","spots"]},"RegionShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"regionCode":{"type":["string","null"],"description":"행정동 코드 — missions.region_code 는 NULL 허용 컬럼이라 없을 수 있다"}}}],"description":"구역(AREA) — region_code 만(경계는 region API 로 별도 조회)","required":["regionCode"]},"Spot":{"type":"object","description":"코스 포토스팟 마커","properties":{"gridId":{"type":"string"},"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"},"seq":{"type":["integer","null"],"format":"int32","description":"코스 내 순번 — mission_grids.seq 는 NULL 허용 컬럼이라 없을 수 있다"},"name":{"type":["string","null"],"description":"표시 이름 (MSG-492) — 명소 이름·구역 표시명(\"서면 A-14\")·행정동 이름 중 하나로 이미 조립된 문자열이다. 시더가 적재 시점에 정해 저장한 값을 그대로 통과시킨다. 코스가 아닌 유형의 스팟과 시더 갱신 전 스팟만 null — 화면은 기존 안내 문구를 폴백으로 남긴다"}},"required":["gridId","lat","lng","name","seq"]},"SpotStats":{"type":"object","description":"코스 포토스팟 하나의 방문 여부·영상 개수","properties":{"gridId":{"type":"string","description":"포토스팟 격자 id — shape.spots 의 gridId 에 대응","example":"38677_114635"},"visited":{"type":"boolean","description":"미션 기간 안에 촬영한 내 영상이 있는지 — 진행도와 같은 술어. 비로그인 조회면 항상 false (MSG-454)","example":true},"videoCount":{"type":"integer","format":"int64","description":"이 스팟에 올라온 전역 공개 영상 수. 영상이 없으면 0","example":9}},"required":["gridId","videoCount","visited"]},"ApiResponseDtoGridVideoPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridVideoPageResponseDto"}},"required":["data","developCode","message"]},"GridGlobalVideoResponseDto":{"type":"object","description":"전역 공개 영상 목록 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID. 항목 탭 → 단건 재생(GET /api/videos/{videoId}) 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL. 목록은 READY 만 담겨 null 아님이 기대값이다"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"viewCount":{"type":"integer","format":"int64","description":"조회수","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-20T18:03:11Z"},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"}},"required":["durationSec","nickname","recordedAt","thumbnailUrl","videoId","viewCount"]},"GridVideoPageResponseDto":{"type":"object","description":"전역 공개 영상 목록 페이지 응답 (keyset 커서 페이지네이션)","properties":{"videos":{"type":"array","description":"이 페이지의 전역 공개·READY 영상. 없으면 빈 배열","items":{"$ref":"#/components/schemas/GridGlobalVideoResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null."}},"required":["hasNext","nextCursor","videos"]},"ApiResponseDtoListMissionProgressResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionProgressResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoListMissionRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionRegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"MissionRegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 미션 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드(10자리)를 단위 길이로 자른 접두(동 10, 구 5, 시 2자리). 행정동이 판정되지 않은 묶음만 null","example":"26230"},"name":{"type":["string","null"],"description":"단위 표시 이름 (동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). 무귀속만 null","example":"부산진구"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 묶음에 속한 미션 귀속점의 평균이라 마커가 실제 데이터 위에 선다","example":35.1568},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.0592},"count":{"type":"integer","format":"int32","description":"그 단위 안의 미션 수","example":12},"missionIds":{"type":"array","description":"그 묶음에 속한 미션 id 오름차순 — 줌인 후 개별 조회 결과와 교집합으로 목록을 좁힌다(D5). 크기는 count 와 같다","items":{"type":"integer","format":"int64"}}},"required":["count","lat","lng","missionIds","name","regionCode"]},"ApiResponseDtoListMissionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoHotZoneListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/HotZoneListResponseDto"}},"required":["data","developCode","message"]},"HotZoneListResponseDto":{"type":"object","description":"뷰포트 내 핫구역 목록 응답 (핫스코어 내림차순)","properties":{"hotZones":{"type":"array","description":"핫구역 목록 — 핫스코어 내림차순. 없으면 빈 배열","items":{"$ref":"#/components/schemas/HotZoneResponseDto"}}},"required":["hotZones"]},"HotZoneResponseDto":{"type":"object","description":"핫구역 한 칸 — 최근 48시간 방문(업로드) 신호가 상위인 격자","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":9582},"score":{"type":"integer","format":"int64","description":"핫스코어 — 최근 48시간(8버킷) 방문 신호 합산","example":12},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 마커 라벨은 같은 항목의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단) — 마커 배지용. zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 부산진구 부전1동"}},"required":["gridId","gridX","gridY","regionName","score","zoneCell","zoneName"]},"ApiResponseDtoListHotZoneRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/HotZoneRegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"HotZoneRegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 핫구역 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드(10자리)를 단위 길이로 자른 접두(동 10, 구 5, 시 2자리). 행정동이 판정되지 않은 묶음만 null","example":"26230"},"name":{"type":["string","null"],"description":"단위 표시 이름 (동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). 무귀속만 null","example":"부산진구"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 묶음에 속한 핫 격자 셀 중심의 평균이라 마커가 실제 데이터 위에 선다","example":35.1568},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.0592},"count":{"type":"integer","format":"int32","description":"그 단위 안의 핫 격자 수 — 핫스코어 합산이 아니다","example":12},"gridIds":{"type":"array","description":"그 묶음에 속한 핫 격자 id 오름차순 — 줌인 후 개별 조회 결과와 교집합으로 목록을 좁힌다(D4). 크기는 count 와 같다","items":{"type":"string"}}},"required":["count","gridIds","lat","lng","name","regionCode"]},"ApiResponseDtoOccupiedGridPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OccupiedGridPageResponseDto"}},"required":["data","developCode","message"]},"OccupiedGridPageResponseDto":{"type":"object","description":"뷰포트 색칠 격자 페이지 응답 (커서 페이지네이션)","properties":{"grids":{"type":"array","description":"이 페이지의 색칠 격자 목록 ((grid_y, grid_x) 오름차순)","items":{"$ref":"#/components/schemas/OccupiedGridResponseDto"}},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null.","example":"MTk0MjJfOTU4Mg=="}},"required":["grids","nextCursor"]},"OccupiedGridResponseDto":{"type":"object","description":"뷰포트 색칠 격자 한 칸 — 지도 렌더링용 위치 정보","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":9582},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 표시 이름은 같은 항목의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단) — 셀 배지용. zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 부산진구 부전1동"}},"required":["gridId","gridX","gridY","regionName","zoneCell","zoneName"]},"ApiResponseDtoGridCellResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridCellResponseDto"}},"required":["data","developCode","message"]},"GridCellResponseDto":{"type":"object","description":"단일 격자의 내 색칠(점령) 상태. 미점령이어도 404가 아니라 occupied=false로 응답한다.","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"occupied":{"type":"boolean","description":"내가 이 격자를 점령(색칠)했는지 여부","example":true},"videoCount":{"type":"integer","format":"int32","description":"이 격자에 올린 내 영상 수 (미점령이면 0)","example":3},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 표시 이름은 같은 응답의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단). zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 아직 아무도 영상을 올리지 않은 격자에도 실린다. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 영도구 영선1동"}},"required":["gridId","occupied","regionName","videoCount","zoneCell","zoneName"]},"ApiResponseDtoListGridVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridVideoResponseDto"}}},"required":["data","developCode","message"]},"GridVideoResponseDto":{"type":"object","description":"격자별 내 영상 리스트 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키","example":1042},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"}},"required":["createdAt","durationSec","processingStatus","thumbnailUrl","videoId"]},"ApiResponseDtoListGridMissionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridMissionResponseDto"}}},"required":["data","developCode","message"]},"GridMissionResponseDto":{"type":"object","description":"격자가 대표 격자인 미션","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 ID — 상세(GET /api/missions/{missionId})로 넘어가는 키","example":412},"type":{"type":"string","description":"미션 종류 — EVENT(지역축제) 또는 POPUP(팝업스토어)","example":"EVENT"},"title":{"type":"string","description":"미션 이름","example":"부산 불꽃축제"},"startAt":{"type":["string","null"],"format":"date-time","description":"시작 시각","example":"2026-10-01T00:00:00Z"},"endAt":{"type":["string","null"],"format":"date-time","description":"종료 시각","example":"2026-10-07T14:59:59Z"},"videoCount":{"type":"integer","format":"int64","description":"미션 기간 안에 촬영된 전역 공개 영상 수 — 미션 상세의 videoCount 와 같은 술어다","example":37}},"required":["endAt","missionId","startAt","title","type","videoCount"]},"ApiResponseDtoGridHourlyUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridHourlyUploadResponseDto"}},"required":["data","developCode","message"]},"GridHourlyUploadResponseDto":{"type":"object","description":"격자 전역 시간대 분포 응답 (KST 24구간)","properties":{"gridId":{"type":"string","description":"격자 ID","example":"19422_9582"},"hours":{"type":"array","description":"KST 0시부터 23시까지 24개 구간. 업로드가 없는 구간은 count 0","items":{"$ref":"#/components/schemas/HourlyUploadCountResponseDto"}}},"required":["gridId","hours"]},"HourlyUploadCountResponseDto":{"type":"object","description":"시간대 구간 하나의 업로드 수","properties":{"hour":{"type":"integer","format":"int32","description":"KST 기준 시 (0~23)","example":18},"count":{"type":"integer","format":"int64","description":"그 시간대의 전역 공개 영상 수. 업로드가 없으면 0","example":3}},"required":["count","hour"]},"ApiResponseDtoListGridEventLocationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridEventLocationResponseDto"}}},"required":["data","developCode","message"]},"GridEventLocationResponseDto":{"type":"object","description":"격자 역조회 결과 하나 — 회차와 해석된 행사 위치","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"소속 행사 회차 id","example":12},"occurrenceTitle":{"type":"string","description":"행사명","example":"부산불꽃축제"},"occurrenceStatus":{"type":"string","description":"서버 시각 기준 파생 상태 — 상세와 같은 계산","enum":["UPCOMING","LIVE","UPLOAD_GRACE","ARCHIVED"],"example":"LIVE"},"locationId":{"type":"integer","format":"int64","description":"해석된 행사 위치 id — 피드 진입 키","example":31},"locationName":{"type":"string","description":"위치 이름","example":"부산역 팝업"},"representativeGridId":{"type":"string","description":"대표 격자 — 피드(MSG-440)가 영상을 붙일 격자","example":"19443_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 안 위치 코드. 구역 밖이면 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자의 행정동 이름 — 구역 밖 표시명 폴백. 무귀속이면 null","example":"부전동"}},"required":["locationId","locationName","occurrenceId","occurrenceStatus","occurrenceTitle","regionName","representativeGridId","zoneCell","zoneName"]},"ApiResponseDtoGridCoverVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/GridCoverVideoResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"GridCoverVideoResponseDto":{"type":"object","description":"격자 전역 대표 영상","properties":{"videoId":{"type":"integer","format":"int64","description":"대표 영상 ID. 개별 재생 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL. 대표는 항상 READY 라 null 이 아니다"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"viewCount":{"type":"integer","format":"int64","description":"조회수 — 대표 선정 정렬 키","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용). 정렬 tie-break 키는 createdAt 이다","example":"2026-07-20T18:03:11Z"},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"}},"required":["durationSec","nickname","recordedAt","thumbnailUrl","videoId","viewCount"]},"ApiResponseDtoGridAggregationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridAggregationResponseDto"}},"required":["data","developCode","message"]},"CurrentRegionResponseDto":{"type":"object","description":"뷰포트 중심이 속한 현재 행정동과 개인 점령 요약","properties":{"regionCode":{"type":"string","description":"행정동 코드 10자리","example":"2623058000"},"name":{"type":"string","description":"동 이름 한 토큰","example":"부전2동"},"gridCount":{"type":"integer","format":"int32","description":"이 행정동 전체에서 내가 점령한 격자 수(뷰포트 무관)","example":5},"videoCount":{"type":"integer","format":"int64","description":"이 행정동 전체에서 내 격자에 올린 영상 수(뷰포트 무관)","example":355}},"required":["gridCount","name","regionCode","videoCount"]},"GridAggregationResponseDto":{"type":"object","description":"뷰포트 점령 격자 묶음과 현재 동네 집계","properties":{"currentRegion":{"anyOf":[{"$ref":"#/components/schemas/CurrentRegionResponseDto"},{"type":"null"}],"description":"뷰포트 중심이 속한 행정동. 해상이나 서비스 범위 밖이면 null"},"items":{"type":"array","description":"뷰포트 안에서 행정 단위로 묶은 내 점령 격자 목록","items":{"$ref":"#/components/schemas/RegionAggregateResponseDto"}}},"required":["currentRegion","items"]},"RegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 점령 격자 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드를 단위 길이로 자른 접두(동 10자리, 구 5자리, 시 2자리). 행정동이 판정되지 않은 격자 묶음만 null 이다.","example":"2623058000"},"name":{"type":["string","null"],"description":"단위 표시 이름(동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). \"부산광역시 214\" 를 \"부산 214\" 로 줄이는 표기 축약은 클라이언트 몫이다. 행정동이 판정되지 않은 격자 묶음만 null 이다.","example":"부전2동"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 그 묶음에 속한 점령 격자 중심의 평균이다(행정 경계 무게중심이 아니다)","example":35.162},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.065},"count":{"type":"integer","format":"int32","description":"그 단위 안 점령 격자 수. 항목을 더 묶어 합산해도 같은 뷰포트 개별 조회 총수와 일치한다","example":31}},"required":["count","lat","lng","name","regionCode"]},"ApiResponseDtoListFriendListItemResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FriendListItemResponseDto"}}},"required":["data","developCode","message"]},"FriendListItemResponseDto":{"type":"object","description":"친구 목록 항목 — 수락된 친구 한 명.","properties":{"userId":{"type":"integer","format":"int64","description":"친구의 사용자 id — 프로필 조회·친구 삭제 경로 변수로 그대로 쓴다","example":7},"nickname":{"type":"string","description":"친구의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"친구의 프로필 이미지 URL — 미설정이면 null"},"gridColor":{"type":"string","description":"친구의 도감 색상 — 지도에서 친구가 수집한 격자를 칠하는 색","enum":["BLUE","GREEN","PURPLE","ORANGE","PINK","YELLOW","RED","TEAL"],"example":"PINK"}},"required":["gridColor","nickname","profileImageUrl","userId"]},"ApiResponseDtoFriendProfileResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendProfileResponseDto"}},"required":["data","developCode","message"]},"CollectionSummaryResponseDto":{"type":"object","description":"개인 도감 요약 — 점령한 격자 수·올린 영상 총합·방문한 행정동 수·현재/최장 스트릭·획득 뱃지 수.","properties":{"totalGridCount":{"type":"integer","format":"int32","description":"내가 점령한 격자 수 (도감 크기)","example":15},"totalVideoCount":{"type":"integer","format":"int64","description":"내가 올린 영상 총합 (활성 영상만)","example":42},"visitedRegionCount":{"type":"integer","format":"int32","description":"내가 방문한 서로 다른 행정동 수","example":6},"currentStreak":{"type":"integer","format":"int32","description":"현재 스트릭 (연속 업로드 일수). 마지막 기록이 KST 그제 이전이면 끊긴 것으로 보고 0","example":12},"maxStreak":{"type":"integer","format":"int32","description":"최장 스트릭. 끊겨도 유지되는 역대 최고 기록","example":21},"badgeCount":{"type":"integer","format":"int32","description":"획득한 뱃지 수","example":7}},"required":["badgeCount","currentStreak","maxStreak","totalGridCount","totalVideoCount","visitedRegionCount"]},"FriendCollectionGridResponseDto":{"type":"object","description":"친구가 수집한 격자 하나 — 썸네일은 재생 허용 영상이 있을 때만 붙는다.","properties":{"gridId":{"type":"string","description":"격자 ID \"{grid_y}_{grid_x}\"","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 Y 인덱스(지도 이동용, gridId 디코드값)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 X 인덱스(지도 이동용, gridId 디코드값)","example":9582},"firstCollectedAt":{"type":"string","format":"date-time","description":"친구가 이 격자를 처음 수집한 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"lastUploadedAt":{"type":"string","format":"date-time","description":"친구의 마지막 업로드 시각","example":"2026-07-21T09:12:00Z"},"videoCount":{"type":"integer","format":"int32","description":"그 격자에 친구가 올린 영상 수","example":3},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL — 재생 허용 영상이 없으면 null"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름(무귀속/미판정이면 null)","example":"서울특별시 강남구 역삼1동"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["firstCollectedAt","gridId","gridX","gridY","lastUploadedAt","regionName","thumbnailUrl","videoCount","zoneCell","zoneName"]},"FriendProfileResponseDto":{"type":"object","description":"친구 프로필 — 프로필 정보와 도감 요약·최근 수집 격자.","properties":{"nickname":{"type":"string","description":"친구의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"친구의 프로필 이미지 URL — 미설정이면 null"},"gridColor":{"type":"string","description":"친구의 도감 색상","enum":["BLUE","GREEN","PURPLE","ORANGE","PINK","YELLOW","RED","TEAL"],"example":"PINK"},"summary":{"$ref":"#/components/schemas/CollectionSummaryResponseDto","description":"친구의 도감 요약 — 본인이 보는 값과 동일하다"},"recentGrids":{"type":"array","description":"친구가 최근 수집한 격자 최대 30개 — 수집 시각 역순","items":{"$ref":"#/components/schemas/FriendCollectionGridResponseDto"}}},"required":["gridColor","nickname","profileImageUrl","recentGrids","summary"]},"ApiResponseDtoListFriendGridVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FriendGridVideoResponseDto"}}},"required":["data","developCode","message"]},"FriendGridVideoResponseDto":{"type":"object","description":"친구 격자 영상 리스트 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 재생 조회 진입 키","example":1042},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. 썸네일 key 가 없으면 null"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"}},"required":["createdAt","durationSec","thumbnailUrl","videoId"]},"ApiResponseDtoListRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoListReceivedFriendRequestResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ReceivedFriendRequestResponseDto"}}},"required":["data","developCode","message"]},"ReceivedFriendRequestResponseDto":{"type":"object","description":"받은 친구 요청 응답 — 최신 요청 우선 정렬.","properties":{"requesterId":{"type":"integer","format":"int64","description":"보낸 사용자 id — 수락/거절 호출의 경로 변수로 그대로 쓴다","example":3},"nickname":{"type":"string","description":"보낸 사용자의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"보낸 사용자의 프로필 이미지 URL — 미설정이면 null"},"requestedAt":{"type":"string","format":"date-time","description":"요청 시각","example":"2026-08-03T12:00:00Z"}},"required":["nickname","profileImageUrl","requestedAt","requesterId"]},"ApiResponseDtoFriendPreviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendPreviewResponseDto"}},"required":["data","developCode","message"]},"FriendPreviewResponseDto":{"type":"object","description":"친구 코드 미리보기 응답 — 요청 확정 전 확인 화면(\"OOO님에게 요청을 보낼까요?\")용. 관계 상태(relation)를 함께 담아 화면이 요청 버튼의 활성 여부·문구를 미리 정할 수 있다 (MSG-391). 조회 전용이며 요청 API 가 전 검증을 재수행한다.","properties":{"nickname":{"type":"string","description":"코드 소유자의 닉네임 — SELF 면 내 닉네임","example":"채우미"},"relation":{"type":"string","description":"조회자와 코드 소유자의 관계 상태 — 조회 시점 실시간 판정 (MSG-391)","enum":["SELF","NONE","OUTGOING_PENDING","INCOMING_PENDING","FRIENDS"],"example":"NONE"}},"required":["nickname","relation"]},"ApiResponseDtoFriendCodeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendCodeResponseDto"}},"required":["data","developCode","message"]},"FriendCodeResponseDto":{"type":"object","description":"내 친구 코드 응답","properties":{"friendCode":{"type":"string","description":"고정 친구 코드 — 혼동 문자(I·O·0·1) 제외 32종 8자, 재발급 없음","example":"AB3DE7GH"}},"required":["friendCode"]},"ApiResponseDtoEventVideoDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoDetailResponseDto"}},"required":["data","developCode","message"]},"EventVideoCommentPageResponseDto":{"type":"object","description":"행사 영상 댓글 페이지 (keyset 커서 페이지네이션)","properties":{"comments":{"type":"array","description":"이 페이지의 댓글 (오래된 순). 댓글이 없으면 빈 배열","items":{"$ref":"#/components/schemas/EventVideoCommentResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 그대로 넣는다. 마지막 페이지면 null"}},"required":["comments","hasNext","nextCursor"]},"EventVideoDetailResponseDto":{"type":"object","description":"행사 영상 상세","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID","example":1042},"occurrenceId":{"type":"integer","format":"int64","description":"소속 행사 회차 ID","example":12},"occurrenceStatus":{"type":"string","description":"요청 시점 회차 상태 (UPCOMING/LIVE/UPLOAD_GRACE/ARCHIVED)","example":"LIVE"},"locationId":{"type":"integer","format":"int64","description":"소속 행사 위치 ID","example":34},"locationName":{"type":"string","description":"소속 행사 위치 이름","example":"영화의전당"},"representativeGridId":{"type":"string","description":"영상이 붙은 대표 격자 ID","example":"19422_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\". zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속이면 null","example":"부산광역시 부산진구 부전2동"},"playbackUrl":{"type":"string","description":"재생본 presigned GET URL"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초)","example":15},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-10-06T12:00:00Z"},"createdAt":{"type":"string","format":"date-time","description":"업로드 시각","example":"2026-10-06T12:30:00Z"},"uploaderNickname":{"type":"string","description":"작성자 닉네임","example":"필맵러"},"interactionLocked":{"type":"boolean","description":"댓글·도움돼요 입력 UI 를 비활성화할지 여부 — 아카이브 전환(행사 종료 + 30일)부터 true","example":false},"helpfulCount":{"type":"integer","format":"int64","description":"도움돼요 수","example":12},"helpfulByMe":{"type":"boolean","description":"내가 도움돼요를 누른 상태인지. 비로그인 조회는 항상 false","example":false},"commentCount":{"type":"integer","format":"int64","description":"댓글 수","example":3},"comments":{"$ref":"#/components/schemas/EventVideoCommentPageResponseDto","description":"댓글 첫 페이지 (오래된 순 20건)"}},"required":["commentCount","comments","createdAt","durationSec","helpfulByMe","helpfulCount","interactionLocked","locationId","locationName","occurrenceId","occurrenceStatus","playbackUrl","recordedAt","regionName","representativeGridId","uploaderNickname","videoId","zoneCell","zoneName"]},"ApiResponseDtoEventVideoCommentPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoCommentPageResponseDto"}},"required":["data","developCode","message"]},"ApiResponseDtoListEventOccurrenceChipResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/EventOccurrenceChipResponseDto"}}},"required":["data","developCode","message"]},"EventOccurrenceChipResponseDto":{"type":"object","description":"뷰포트에 걸친 행사 회차 하나 — 지도 홈 칩 재료","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":12},"title":{"type":"string","description":"행사명 — 칩 라벨 재료","example":"부산불꽃축제"},"cityName":{"type":"string","description":"대상 지역 시 이름 — 시 칩 묶음 기준","example":"부산"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2026-10-06T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2026-10-15T13:00:00Z"},"status":{"type":"string","description":"서버 시각 기준 파생 상태 — 이 목록에는 두 값만 담긴다","enum":["UPCOMING","LIVE"],"example":"LIVE"}},"required":["cityName","endsAt","occurrenceId","startsAt","status","title"]},"ApiResponseDtoEventOccurrenceDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventOccurrenceDetailResponseDto"}},"required":["data","developCode","message"]},"EventOccurrenceDetailResponseDto":{"type":"object","description":"행사 회차 상세 — 이벤트 헤더","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":12},"seriesId":{"type":"integer","format":"int64","description":"행사 시리즈 id — 이전 회차 묶음 기준","example":3},"title":{"type":"string","description":"행사명","example":"부산불꽃축제"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2026-10-06T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2026-10-15T13:00:00Z"},"uploadClosesAt":{"type":"string","format":"date-time","description":"영상 업로드 마감 — 종료 30일 후 파생값","example":"2026-11-14T13:00:00Z"},"status":{"type":"string","description":"서버 시각 기준 파생 상태","enum":["UPCOMING","LIVE","UPLOAD_GRACE","ARCHIVED"],"example":"LIVE"},"notificationOn":{"type":"boolean","description":"알림 구독 여부 — 구독 행 존재이면서 회차가 예정·진행 중일 때만 true. 비로그인은 항상 false 고, 종료된 회차는 구독 행이 남아 있어도 false 다","example":false},"previousOccurrences":{"type":"array","description":"같은 시리즈의 지난 회차 — 최신순. 없으면 빈 배열","items":{"$ref":"#/components/schemas/PreviousOccurrenceDto"}}},"required":["endsAt","notificationOn","occurrenceId","previousOccurrences","seriesId","startsAt","status","title","uploadClosesAt"]},"PreviousOccurrenceDto":{"type":"object","description":"같은 시리즈의 지난 회차 하나","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":9},"title":{"type":"string","description":"행사명","example":"부산불꽃축제"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2025-10-04T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2025-10-13T13:00:00Z"}},"required":["endsAt","occurrenceId","startsAt","title"]},"ApiResponseDtoEventViewerCountResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventViewerCountResponseDto"}},"required":["data","developCode","message"]},"EventViewerCountResponseDto":{"type":"object","description":"이벤트 현재 열람 인원 응답.","properties":{"viewerCount":{"type":["integer","null"],"format":"int32","description":"현재 열람 인원 — 마지막 heartbeat 가 90초 이내인 고유 세션 수. 0 은 아무도 없음(표시), null 은 캐시 장애(숨김)","example":120}},"required":["viewerCount"]},"ApiResponseDtoListEventLocationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/EventLocationResponseDto"}}},"required":["data","developCode","message"]},"EventLocationResponseDto":{"type":"object","description":"행사 위치 하나 — 영역 격자·대표 격자·표시명 재료·영상 수","properties":{"locationId":{"type":"integer","format":"int64","description":"행사 위치 id — 위치별 영상 피드 진입 키","example":31},"name":{"type":"string","description":"위치 이름","example":"부산역 팝업"},"type":{"type":"string","description":"위치 유형 — 표시 라벨 변환은 FE 몫","enum":["POPUP","EXPERIENCE_ZONE","PARADE","PHOTO_ZONE","ETC"],"example":"POPUP"},"operatingHours":{"type":["string","null"],"description":"운영 시간 표시 문자열","example":"11:00 ~ 20:00"},"gridIds":{"type":"array","description":"영역을 구성하는 격자 전체 — FE 영역 채색 재료","example":["19443_9582"],"items":{"type":"string"}},"representativeGridId":{"type":"string","description":"대표 격자 — 이 위치의 영상이 붙는 단 하나의 격자","example":"19443_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 안 위치 코드. 구역 밖이면 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자의 행정동 이름 — 구역 밖 표시명 폴백. 무귀속이면 null","example":"부전동"},"videoCount":{"type":"integer","format":"int64","description":"이 위치의 영상 수 — 조회 시점 실측(전역 노출 게이트 통과분)","example":7},"organizerName":{"type":["string","null"],"description":"운영 주체 — 참여형 승인분만 값이 있다","example":"필맵 주식회사"},"description":{"type":["string","null"],"description":"참여 소개 — 참여형 승인분만 값이 있다"},"participationStartsOn":{"type":["string","null"],"format":"date","description":"공개 시작일 (표기 정보, 노출 창 아님)","example":"2026-11-07"},"participationEndsOn":{"type":["string","null"],"format":"date","description":"공개 종료일 (표기 정보, 노출 창 아님)","example":"2026-11-09"},"participationMethod":{"type":["string","null"],"description":"참여 방식 서술 — 참여형 승인분만 값이 있다"},"imageUrl":{"type":["string","null"],"description":"커버 이미지 공개 URL — 참여형 승인분만 값이 있다"}},"required":["description","gridIds","imageUrl","locationId","name","operatingHours","organizerName","participationEndsOn","participationMethod","participationStartsOn","regionName","representativeGridId","type","videoCount","zoneCell","zoneName"]},"ApiResponseDtoEventLocationVideoPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventLocationVideoPageResponseDto"}},"required":["data","developCode","message"]},"EventLocationVideoPageResponseDto":{"type":"object","description":"위치별 영상 피드 페이지 (keyset 커서 페이지네이션)","properties":{"videos":{"type":"array","description":"이 페이지의 영상. 조건에 맞는 영상이 없으면 빈 배열","items":{"$ref":"#/components/schemas/EventLocationVideoResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 그대로 넣는다. 마지막 페이지면 null"}},"required":["hasNext","nextCursor","videos"]},"EventLocationVideoResponseDto":{"type":"object","description":"위치별 영상 피드 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID — 상세 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초)","example":15},"createdAt":{"type":"string","format":"date-time","description":"업로드 시각","example":"2026-10-06T12:30:00Z"},"helpfulCount":{"type":"integer","format":"int64","description":"도움돼요 수","example":12},"commentCount":{"type":"integer","format":"int64","description":"댓글 수","example":3}},"required":["commentCount","createdAt","durationSec","helpfulCount","thumbnailUrl","videoId"]},"ApiResponseDtoListRegionVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionVideoResponseDto"}}},"required":["data","developCode","message"]},"RegionVideoResponseDto":{"type":"object","description":"동 단위 내 영상 리스트 항목 — 그 행정동 격자들에 올린 내 영상 하나.","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키","example":1042},"gridId":{"type":"string","description":"영상이 속한 격자 ID \"{grid_y}_{grid_x}\" — 항목별 격자 라벨·지도 이동용","example":"19422_9582"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이 화면은 행정동 헤더 아래 목록이라 폴백 이름을 문맥에서 알 수 있어 항목에 regionName 을 따로 담지 않는다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["createdAt","durationSec","gridId","processingStatus","thumbnailUrl","videoId","zoneCell","zoneName"]},"ApiResponseDtoListUploadHistoryResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/UploadHistoryResponseDto"}}},"required":["data","developCode","message"]},"UploadHistoryResponseDto":{"type":"object","description":"날짜별 업로드 기록 항목 — 업로드가 있었던 KST 날짜 하나와 그날의 건수.","properties":{"uploadDate":{"type":"string","format":"date","description":"업로드가 있었던 KST 날짜","example":"2026-08-11"},"uploadCount":{"type":"integer","format":"int32","description":"그날 업로드한 영상 수 (1 이상)","example":3}},"required":["uploadCount","uploadDate"]},"ApiResponseDtoCollectionSummaryResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/CollectionSummaryResponseDto"}},"required":["data","developCode","message"]},"ApiResponseDtoListCollectionGridResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/CollectionGridResponseDto"}}},"required":["data","developCode","message"]},"CollectionGridResponseDto":{"type":"object","description":"갤러리 격자 항목 — 내가 수집한 격자 하나와 cover 썸네일.","properties":{"gridId":{"type":"string","description":"격자 ID \"{grid_y}_{grid_x}\"","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 Y 인덱스(지도 이동용, gridId 디코드값)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 X 인덱스(지도 이동용, gridId 디코드값)","example":9582},"firstCollectedAt":{"type":"string","format":"date-time","description":"최초 수집(점령) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"lastUploadedAt":{"type":"string","format":"date-time","description":"마지막 방문(업로드) 시각","example":"2026-07-21T09:12:00Z"},"videoCount":{"type":"integer","format":"int32","description":"그 격자 내 내 영상 수","example":3},"coverVideoId":{"type":["integer","null"],"format":"int64","description":"cover 영상 ID(없으면 null)","example":1042},"coverThumbnailUrl":{"type":["string","null"],"description":"cover 썸네일 presigned GET URL(없거나 READY 이전이면 null)"},"coverDurationSec":{"type":["integer","null"],"format":"int32","description":"cover 영상 길이(초) — 카드 duration 뱃지 재료. READY 이전에도 실리고 cover 자체가 없을 때만 null","example":12},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름(무귀속/미판정이면 null)","example":"서울특별시 강남구 역삼1동"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["coverDurationSec","coverThumbnailUrl","coverVideoId","firstCollectedAt","gridId","gridX","gridY","lastUploadedAt","regionName","videoCount","zoneCell","zoneName"]},"ApiResponseDtoListMyBadgeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MyBadgeResponseDto"}}},"required":["data","developCode","message"]},"MyBadgeResponseDto":{"type":"object","description":"내 뱃지 목록 행 — 획득+미획득 (은퇴 뱃지는 획득자에게만)","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":2},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_10"},"name":{"type":"string","description":"표시명","example":"탐험가 I"},"description":{"type":["string","null"],"description":"설명 — badges.description 은 NULL 허용 컬럼이다","example":"격자 10개를 수집했어요"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null},"earned":{"type":"boolean","description":"획득 여부","example":true},"earnedAt":{"type":["string","null"],"format":"date-time","description":"획득 시각 — 미획득이면 null","example":"2026-07-29T11:02:31Z"},"isNew":{"type":"boolean","description":"미확인(새 뱃지) 여부 — 미획득이면 false","example":false},"featuredRank":{"type":["integer","null"],"format":"int32","description":"대표 뱃지 순서(1·2) — 대표 아니면 null","example":1}},"required":["badgeId","code","description","earned","earnedAt","featuredRank","iconUrl","isNew","name"]},"ApiResponseDtoPasswordStatusResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/PasswordStatusResponseDto"}},"required":["data","developCode","message"]},"PasswordStatusResponseDto":{"type":"object","description":"비밀번호 강제 변경 상태","properties":{"mustChange":{"type":"boolean","description":"true 면 비밀번호를 바꾸기 전까지 행사 등재 콘솔이 막힌다","example":true}},"required":["mustChange"]},"AdminVideoReviewResponseDto":{"type":"object","description":"관리자 단건 영상 확인 응답 — 영상 메타와 재생·썸네일 presigned GET URL.","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID","example":1042},"status":{"type":"string","description":"영상 상태 — BLINDED 여도 발급된다 (DELETED 만 404)","enum":["ACTIVE","BLINDED","DELETED"],"example":"BLINDED"},"processingStatus":{"type":"string","description":"영상 처리 상태 — READY 일 때만 재생 URL 이 발급된다","enum":["UPLOADED","ENCODING","BLURRING","READY","FAILED"],"example":"READY"},"visibility":{"type":"string","description":"공개 범위 — PRIVATE 여도 발급된다 (관리자 확인은 은닉 없음)","enum":["PUBLIC","PRIVATE","FRIENDS"],"example":"PRIVATE"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용)","example":"2026-07-20T18:03:11Z"},"playbackUrl":{"type":["string","null"],"description":"재생본 presigned GET URL — READY 가 아니면 null"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL — 썸네일 key 없음(READY 이전)이면 null"},"expiresInSec":{"type":["integer","null"],"format":"int64","description":"playbackUrl presign TTL(초) — playbackUrl=null 이면 null","example":600}},"required":["durationSec","expiresInSec","playbackUrl","processingStatus","recordedAt","status","thumbnailUrl","videoId","visibility"]},"ApiResponseDtoAdminVideoReviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminVideoReviewResponseDto"}},"required":["data","developCode","message"]},"AdminReportItemResponseDto":{"type":"object","description":"관리자 신고 목록 항목 — 신고 한 건과 판단에 필요한 주변 정보.","properties":{"reportId":{"type":"integer","format":"int64","description":"신고 ID — 승인·기각 경로 변수로 그대로 쓴다","example":7},"status":{"type":"string","description":"신고 처리 상태","enum":["PENDING","REVIEWING","RESOLVED","REJECTED"],"example":"PENDING"},"reason":{"type":"string","description":"신고 사유","enum":["INAPPROPRIATE","PRIVACY","SPAM","COPYRIGHT","OTHER"],"example":"INAPPROPRIATE"},"detail":{"type":["string","null"],"description":"신고자가 적은 상세 설명 — OTHER 가 아닌 사유는 없을 수 있다"},"createdAt":{"type":"string","format":"date-time","description":"신고 접수 시각","example":"2026-08-06T10:15:00Z"},"reporterId":{"type":"integer","format":"int64","description":"신고자의 사용자 ID","example":3},"reporterNickname":{"type":"string","description":"신고자의 닉네임","example":"정민"},"videoId":{"type":"integer","format":"int64","description":"신고 대상 영상 ID — 단건 확인·블라인드 해제 경로 변수로 쓴다","example":1042},"videoStatus":{"type":"string","description":"신고 대상 영상의 현재 상태 (ACTIVE/BLINDED/DELETED)","enum":["ACTIVE","BLINDED","DELETED"],"example":"ACTIVE"},"videoOwnerNickname":{"type":"string","description":"영상 소유자의 닉네임","example":"성민"},"reviewedBy":{"type":["integer","null"],"format":"int64","description":"처리한 관리자의 사용자 ID — 미처리면 null","example":1},"reviewedAt":{"type":["string","null"],"format":"date-time","description":"처리 시각 — 미처리면 null","example":"2026-08-06T11:00:00Z"}},"required":["createdAt","detail","reason","reportId","reporterId","reporterNickname","reviewedAt","reviewedBy","status","videoId","videoOwnerNickname","videoStatus"]},"AdminReportListResponseDto":{"type":"object","description":"관리자 신고 목록 응답 — 상태 필터 기준 한 페이지.","properties":{"items":{"type":"array","description":"이 페이지의 신고 목록. 정렬은 접수 최신순 고정","items":{"$ref":"#/components/schemas/AdminReportItemResponseDto"}},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"totalElements":{"type":"integer","format":"int64","description":"필터에 해당하는 전체 신고 수","example":1},"totalPages":{"type":"integer","format":"int32","description":"전체 페이지 수","example":1}},"required":["items","page","size","totalElements","totalPages"]},"ApiResponseDtoAdminReportListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReportListResponseDto"}},"required":["data","developCode","message"]},"AdminOrgAccountItemResponseDto":{"type":"object","description":"발급된 행사 운영자 계정","properties":{"userId":{"type":"integer","format":"int64","description":"계정 id","example":42},"orgName":{"type":["string","null"],"description":"기관명. 이 발급 경로 밖에서 만들어진 계정이면 null 일 수 있다","example":"부산진구청"},"contactName":{"type":"string","description":"담당자 이름","example":"김담당"},"email":{"type":"string","description":"공식 이메일 (계정 아이디)","example":"event@busanjin.go.kr"},"contactPhone":{"type":["string","null"],"description":"담당자 연락처. 직접 발급에서 생략했으면 null 이다","example":"010-1234-5678"},"provider":{"type":"string","description":"로그인 제공자. 목록이 LOCAL 만 담는다는 사실의 확인 재료다","example":"LOCAL"},"mustChange":{"type":"boolean","description":"초기 비밀번호 변경 강제 여부. true 면 초기 로그인 전, false 면 사용 중이다","example":true},"createdAt":{"type":"string","format":"date-time","description":"발급 시각"}},"required":["contactName","contactPhone","createdAt","email","mustChange","orgName","provider","userId"]},"AdminOrgAccountListResponseDto":{"type":"object","description":"발급된 행사 운영자 계정 목록 — 발급 최신순 한 페이지.","properties":{"totalElements":{"type":"integer","format":"int64","description":"조건에 해당하는 전체 계정 수","example":12},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"accounts":{"type":"array","description":"이 페이지의 계정 목록. 정렬은 발급 최신순 고정","items":{"$ref":"#/components/schemas/AdminOrgAccountItemResponseDto"}}},"required":["accounts","page","size","totalElements"]},"ApiResponseDtoAdminOrgAccountListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminOrgAccountListResponseDto"}},"required":["data","developCode","message"]},"AdminOrgAccountRequestItemResponseDto":{"type":"object","description":"계정 발급 요청 목록 항목","properties":{"id":{"type":"integer","format":"int64","description":"요청 id","example":7},"orgName":{"type":"string","description":"기관명","example":"부산진구청"},"contactName":{"type":"string","description":"담당자 이름","example":"김담당"},"email":{"type":"string","description":"공식 이메일","example":"event@busanjin.go.kr"},"eventName":{"type":"string","description":"예정 행사명","example":"서면 겨울 축제"},"status":{"type":"string","description":"처리 상태 (PENDING, ISSUED, REJECTED)","example":"PENDING"},"createdAt":{"type":"string","format":"date-time","description":"최초 접수 시각"},"updatedAt":{"type":"string","format":"date-time","description":"마지막 접수 시각 — 정렬 기준이자 심사의 검토 기준 시각"}},"required":["contactName","createdAt","email","eventName","id","orgName","status","updatedAt"]},"AdminOrgAccountRequestListResponseDto":{"type":"object","description":"계정 발급 요청 목록 — 상태 필터 기준 한 페이지와 상태별 전체 건수.","properties":{"pendingCount":{"type":"integer","format":"int64","description":"대기 건수 (필터와 무관한 전체 집계)","example":3},"issuedCount":{"type":"integer","format":"int64","description":"발급됨 건수 (필터와 무관한 전체 집계)","example":12},"rejectedCount":{"type":"integer","format":"int64","description":"반려 건수 (필터와 무관한 전체 집계)","example":2},"totalElements":{"type":"integer","format":"int64","description":"필터에 해당하는 전체 요청 수","example":3},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"requests":{"type":"array","description":"이 페이지의 요청 목록. 정렬은 마지막 접수 최신순 고정","items":{"$ref":"#/components/schemas/AdminOrgAccountRequestItemResponseDto"}}},"required":["issuedCount","page","pendingCount","rejectedCount","requests","size","totalElements"]},"ApiResponseDtoAdminOrgAccountRequestListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminOrgAccountRequestListResponseDto"}},"required":["data","developCode","message"]},"AdminOrgAccountRequestDetailResponseDto":{"type":"object","description":"계정 발급 요청 상세","properties":{"id":{"type":"integer","format":"int64","description":"요청 id","example":7},"orgName":{"type":"string","description":"기관명","example":"부산진구청"},"contactName":{"type":"string","description":"담당자 이름","example":"김담당"},"contactPhone":{"type":"string","description":"담당자 연락처","example":"010-1234-5678"},"email":{"type":"string","description":"공식 이메일","example":"event@busanjin.go.kr"},"eventName":{"type":"string","description":"예정 행사명","example":"서면 겨울 축제"},"content":{"type":"string","description":"요청 내용"},"status":{"type":"string","description":"처리 상태 (PENDING, ISSUED, REJECTED)","example":"PENDING"},"rejectReason":{"type":["string","null"],"description":"반려 사유. 반려 건에만 값이 있다"},"issuedUserId":{"type":["integer","null"],"format":"int64","description":"발급된 계정 id. 발급 건에만 값이 있다","example":42},"createdAt":{"type":"string","format":"date-time","description":"최초 접수 시각"},"updatedAt":{"type":"string","format":"date-time","description":"마지막 접수 시각 — 승인·반려 요청에 그대로 에코해야 하는 검토 기준 시각"},"processedAt":{"type":["string","null"],"format":"date-time","description":"처리 시각. 승인·반려 건에만 값이 있다"}},"required":["contactName","contactPhone","content","createdAt","email","eventName","id","issuedUserId","orgName","processedAt","rejectReason","status","updatedAt"]},"ApiResponseDtoAdminOrgAccountRequestDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminOrgAccountRequestDetailResponseDto"}},"required":["data","developCode","message"]},"AdminApprovedEventItemResponseDto":{"type":"object","description":"승인 행사 목록 항목","properties":{"submissionId":{"type":"integer","format":"int64","description":"승인 행사 식별자 (= 신청 id)","example":7},"approvalNo":{"type":"string","description":"승인 번호","example":"APR-2026-0001"},"submissionNo":{"type":"string","description":"신청 번호","example":"FM-2026-0007"},"type":{"type":"string","description":"등록 유형","enum":["FESTIVAL","POPUP","EVENT"],"example":"FESTIVAL"},"title":{"type":"string","description":"축제명 / 팝업명","example":"부산불꽃축제"},"organizerName":{"type":"string","description":"주최 기관 — 신청 폼에 적힌 값"},"orgName":{"type":["string","null"],"description":"기관명 — 신청 계정에 등록된 값"},"startsOn":{"type":"string","format":"date","description":"행사 시작일","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일","example":"2026-11-09"},"status":{"type":"string","description":"파생 상태 (UPCOMING 예정 · EXPOSED 노출 중 · ENDED 종료)","example":"EXPOSED"},"unpublished":{"type":"boolean","description":"노출 중지 여부","example":false},"unpublishedAt":{"type":["string","null"],"format":"date-time","description":"노출 중지 시각 (UTC) — 중지되지 않았으면 null"},"unpublishReason":{"type":["string","null"],"description":"노출 중지 사유 — 중지되지 않았으면 null"}},"required":["approvalNo","endsOn","orgName","organizerName","startsOn","status","submissionId","submissionNo","title","type","unpublishReason","unpublished","unpublishedAt"]},"AdminApprovedEventListResponseDto":{"type":"object","description":"승인 행사 목록 — 탭 기준 한 페이지와 탭별 전체 건수.","properties":{"exposedCount":{"type":"integer","format":"int64","description":"노출 중 건수 (탭과 무관한 전체 집계)","example":4},"upcomingCount":{"type":"integer","format":"int64","description":"예정 건수 (탭과 무관한 전체 집계)","example":2},"endedCount":{"type":"integer","format":"int64","description":"종료 건수 (탭과 무관한 전체 집계)","example":9},"totalElements":{"type":"integer","format":"int64","description":"탭에 해당하는 전체 행사 수","example":4},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"events":{"type":"array","description":"이 페이지의 행사 목록. 정렬은 시작일 최신순 고정","items":{"$ref":"#/components/schemas/AdminApprovedEventItemResponseDto"}}},"required":["endedCount","events","exposedCount","page","size","totalElements","upcomingCount"]},"ApiResponseDtoAdminApprovedEventListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminApprovedEventListResponseDto"}},"required":["data","developCode","message"]},"AdminEventSubmissionItemResponseDto":{"type":"object","description":"관리자 심사 큐 항목","properties":{"id":{"type":"integer","format":"int64","description":"신청 id","example":7},"submissionNo":{"type":"string","description":"신청 번호","example":"FM-2026-0007"},"type":{"type":"string","description":"등록 유형","enum":["FESTIVAL","POPUP","EVENT"],"example":"FESTIVAL"},"status":{"type":"string","description":"신청 상태","enum":["IN_REVIEW","APPROVED","REJECTED"],"example":"IN_REVIEW"},"title":{"type":"string","description":"축제명 / 팝업명","example":"부산불꽃축제"},"organizerName":{"type":"string","description":"주최 기관 — 신청 폼에 적힌 값","example":"부산문화관광축제조직위원회"},"orgName":{"type":["string","null"],"description":"기관명 — 신청 계정에 등록된 값","example":"부산광역시 부산진구청"},"startsOn":{"type":"string","format":"date","description":"행사 시작일","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일","example":"2026-11-07"},"locationCount":{"type":"integer","format":"int32","description":"신청에 담긴 위치 수","example":2},"createdAt":{"type":"string","format":"date-time","description":"접수 시각 (UTC)","example":"2026-08-28T02:00:00Z"},"updatedAt":{"type":"string","format":"date-time","description":"마지막 변경 시각 (UTC)","example":"2026-08-28T02:11:00Z"}},"required":["createdAt","endsOn","id","locationCount","orgName","organizerName","startsOn","status","submissionNo","title","type","updatedAt"]},"AdminEventSubmissionListResponseDto":{"type":"object","description":"관리자 심사 큐 — 상태 필터 기준 한 페이지와 상태별 전체 건수.","properties":{"counts":{"$ref":"#/components/schemas/EventSubmissionStatusCountsResponseDto","description":"상태별 전체 건수 (필터와 무관)"},"totalElements":{"type":"integer","format":"int64","description":"필터에 해당하는 전체 신청 수","example":3},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"submissions":{"type":"array","description":"이 페이지의 신청 목록. 정렬은 접수 최신순 고정","items":{"$ref":"#/components/schemas/AdminEventSubmissionItemResponseDto"}}},"required":["counts","page","size","submissions","totalElements"]},"ApiResponseDtoAdminEventSubmissionListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminEventSubmissionListResponseDto"}},"required":["data","developCode","message"]},"AdminEventSubmissionDetailResponseDto":{"type":"object","description":"관리자 심사 상세","properties":{"id":{"type":"integer","format":"int64","description":"신청 id","example":7},"submissionNo":{"type":"string","description":"신청 번호","example":"FM-2026-0007"},"type":{"type":"string","description":"등록 유형","example":"FESTIVAL"},"status":{"type":"string","description":"신청 상태","example":"IN_REVIEW"},"title":{"type":"string","description":"축제명 / 팝업명"},"organizerName":{"type":"string","description":"주최 기관 — 신청 폼에 적힌 값"},"startsOn":{"type":"string","format":"date","description":"행사 시작일","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일","example":"2026-11-07"},"operatingHours":{"type":["string","null"],"description":"운영 시간 — POPUP 만 값이 있다"},"programDescription":{"type":["string","null"],"description":"주요 프로그램 — FESTIVAL 만 값이 있다"},"participationMethod":{"type":["string","null"],"description":"참여 방식 — EVENT(참여형)만 값이 있다"},"parentEvent":{"anyOf":[{"$ref":"#/components/schemas/EventSubmissionParentEventResponseDto"},{"type":"null"}],"description":"참여할 부모 이벤트 회차 — EVENT(참여형)만 값이 있다"},"description":{"type":"string","description":"행사 소개"},"imageUrl":{"type":"string","description":"대표 이미지 열람용 presigned GET URL"},"orgName":{"type":["string","null"],"description":"신청 계정의 기관명","example":"부산광역시 부산진구청"},"contactName":{"type":"string","description":"신청 계정의 담당자 이름","example":"김담당"},"email":{"type":"string","description":"신청 계정의 공식 이메일 (로그인 아이디)","example":"event@busanjin.go.kr"},"locations":{"type":"array","description":"위치 목록 — 순번 오름차순","items":{"$ref":"#/components/schemas/EventSubmissionLocationResponseDto"}},"exposureRect":{"$ref":"#/components/schemas/EventSubmissionAreaRectDto","description":"전 위치 셀 합집합의 경계 사각형 — 조회 시점 계산이고 저장하지 않는다"},"history":{"type":"array","description":"상태 이력 — 발생 순","items":{"$ref":"#/components/schemas/EventSubmissionHistoryResponseDto"}},"createdAt":{"type":"string","format":"date-time","description":"접수 시각 (UTC)","example":"2026-08-28T02:00:00Z"},"updatedAt":{"type":"string","format":"date-time","description":"마지막 변경 시각 (UTC)","example":"2026-08-28T02:11:00Z"}},"required":["contactName","createdAt","description","email","endsOn","exposureRect","history","id","imageUrl","locations","operatingHours","orgName","organizerName","parentEvent","participationMethod","programDescription","startsOn","status","submissionNo","title","type","updatedAt"]},"ApiResponseDtoAdminEventSubmissionDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminEventSubmissionDetailResponseDto"}},"required":["data","developCode","message"]},"AdminEmailChangeRequestItemResponseDto":{"type":"object","description":"아이디 변경 요청 큐 항목","properties":{"id":{"type":"integer","format":"int64","description":"요청 id","example":3},"userId":{"type":"integer","format":"int64","description":"요청한 계정 id","example":42},"orgName":{"type":["string","null"],"description":"기관명","example":"부산광역시 부산진구청"},"email":{"type":"string","description":"현재 아이디(로그인 이메일)","example":"event@busanjin.go.kr"},"requestedEmail":{"type":"string","description":"바꾸려는 이메일","example":"festival@busanjin.go.kr"},"status":{"type":"string","description":"처리 상태","enum":["PENDING","APPROVED","REJECTED"],"example":"PENDING"},"createdAt":{"type":"string","format":"date-time","description":"마지막 접수 시각 (UTC) — 승인·반려 요청에 되돌려 보내는 검토 기준 시각","example":"2026-08-28T02:00:00Z"},"processedAt":{"type":["string","null"],"format":"date-time","description":"처리 시각 (UTC) — 대기 중이면 null"},"rejectReason":{"type":["string","null"],"description":"반려 사유 — 반려된 요청에만 있다"}},"required":["createdAt","email","id","orgName","processedAt","rejectReason","requestedEmail","status","userId"]},"AdminEmailChangeRequestListResponseDto":{"type":"object","description":"아이디 변경 요청 목록 — 상태 필터 기준 한 페이지와 상태별 전체 건수.","properties":{"pendingCount":{"type":"integer","format":"int64","description":"대기 건수 (필터와 무관한 전체 집계)","example":2},"approvedCount":{"type":"integer","format":"int64","description":"승인 건수 (필터와 무관한 전체 집계)","example":7},"rejectedCount":{"type":"integer","format":"int64","description":"반려 건수 (필터와 무관한 전체 집계)","example":1},"totalElements":{"type":"integer","format":"int64","description":"필터에 해당하는 전체 요청 수","example":2},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"requests":{"type":"array","description":"이 페이지의 요청 목록. 정렬은 마지막 접수 최신순 고정","items":{"$ref":"#/components/schemas/AdminEmailChangeRequestItemResponseDto"}}},"required":["approvedCount","page","pendingCount","rejectedCount","requests","size","totalElements"]},"ApiResponseDtoAdminEmailChangeRequestListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminEmailChangeRequestListResponseDto"}},"required":["data","developCode","message"]}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}}
\ No newline at end of file
+{"openapi":"3.1.0","info":{"title":"FillMap API","description":"FillMap API 문서","version":"v1"},"servers":[{"url":"https://api.fillmap.kr","description":"Generated server url"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"비밀번호 (Password)","description":"비밀번호 상태·변경·재설정 API. 상태·변경은 로그인 필수, 재설정 2종은 비로그인이다."},{"name":"행사 운영자 계정 (Org Account)","description":"담당자 정보 조회·수정과 아이디 변경 요청. 행사 운영자 전용이다."},{"name":"행사 (Events)","description":"지도에서 누른 격자를 행사 위치로 해석하는 역조회 API."},{"name":"미션 영상 (Mission Videos)","description":"미션 상세 하단 \"이 미션의 영상\" 목록 API — 그 미션의 대상 격자에서 미션 기간에 촬영된 공개 영상."},{"name":"이벤트 (Event)","description":"이벤트 열람 인원 heartbeat·조회 API."},{"name":"영상 (Video)","description":"영상 업로드·교체·삭제 API. 업로드는 presigned URL 발급 → S3 직접 업로드 → 메타데이터 저장 순서다."},{"name":"인증 (Auth)","description":"회원가입·로그인·소셜 로그인·토큰 재발급 API. 이 그룹의 엔드포인트는 인증 없이 호출한다."},{"name":"행사 운영자 콘솔 (Org)","description":"행사 운영자 전용 조회 API — 승인 이벤트 목록."},{"name":"알림 (Notification)","description":"FCM 푸시 토큰 등록/갱신·해제 API."},{"name":"장소 검색 (Search)","description":"장소명 자유 텍스트 검색 — 카카오 로컬 키워드 검색 실시간 프록시 + 격자 ID 합성."},{"name":"행사 운영자 계정 발급 요청 (Org Account Request)","description":"계정이 없는 행사 운영자가 발급을 신청하는 공개 폼. 비로그인 호출이다."},{"name":"격자 (Grid)","description":"개인 도감 색칠 격자 조회 API — 로그인 사용자가 점령한 격자만 반환한다.\n\n격자는 EPSG:5179 미터 평면에서 100m 로 나눈 셀이다(2026-08-08 MSG-347 전까지는 위경도 등간격 근사였다). gridId 포맷 `\"{grid_y}_{grid_x}\"` 와 이 API 들의 요청·응답 구조는 그대로지만 **값은 전면 교체됐다** (같은 장소가 `41642_110458` 에서 `19422_9582` 로 바뀌었다). 예전 gridId 를 저장해 둔 클라이언트는 빈 결과를 받으므로 캐시를 비워야 한다.\n\n셀은 위경도 축과 평행하지 않다(자오선 수렴 최대 약 1.6도). 지도에 그릴 때 남서·북동 2점으로 만든 직사각형을 쓰면 어긋나므로 **꼭짓점 4점 폴리곤**으로 그린다. 화면에 보이는 격자 범위를 구할 때도 2점이 아니라 꼭짓점 4점의 min/max 를 써야 가장자리 셀이 빠지지 않는다.\n\n클라이언트가 같은 격자를 계산하려면 서버와 **글자 단위로 같은 proj4 정의**를 써야 한다: `+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs`. 대조용 전국 샘플 200건은 서버 레포 `src/test/resources/fixtures/grid-epsg5179-samples.json` 에 있다."},{"name":"알림 (Notification)","description":"받은 알림 목록 조회와 읽음 처리 API."},{"name":"인증-개발용 (Auth Dev)","description":"로컬/dev 전용 — 소셜 로그인을 실제 소셜 토큰 없이 백엔드에서 테스트. 운영(prod) 미노출."},{"name":"도감 (Collection)","description":"개인 도감 요약 조회 API — 로그인 사용자의 점령·영상·방문 행정동 집계."},{"name":"관리자 행사 등재 심사 (Admin Event Submission)","description":"행사 등재 신청을 검토하고 승인·반려하는 API (MSG-500). ADMIN 권한 필수."},{"name":"관리자 승인 행사 (Admin Approved Event)","description":"승인된 행사의 상태별 조회와 노출 중지 API (MSG-500). ADMIN 권한 필수."},{"name":"격자 상세 (Grid Videos)","description":"격자를 탭했을 때 그 격자의 영상 조회 API — 내 영상 리스트·전역 대표 영상·전역 인기 목록."},{"name":"AI 경로 추천 (Routes)","description":"자연어 한 문장과 뷰포트로 활성 미션·행사·장소 검색 실조회 후보에 방문 순서와 이유를 붙여 돌려준다."},{"name":"구역 (Zone)","description":"구역(\"서면\" 등)의 이름과 격자 사각형 범위. 검색바에서 구역으로 지도를 옮기거나 구역 범위를 오버레이로 그릴 때 쓴다 — 격자 표시명(\"서면 A-14\")은 서버가 계산해 격자 응답에 함께 싣는다."},{"name":"행사 (Events)","description":"행사 위치의 영상 업로드·피드·상세 API."},{"name":"미션 (Missions)","description":"지도 오버레이용 활성 미션 목록·내 진행도·미션 상세 조회 API."},{"name":"알림 (Notification)","description":"카테고리별 알림 수신 설정 조회/토글 API."},{"name":"행정동 (Region)","description":"좌표를 포함하는 행정동을 우리 region_code 체계로 판정하는 역지오코딩 API."},{"name":"인기 검색어 (Trending)","description":"사용자 검색어 일별 집계 기반 인기 검색어 순위 — 오늘+어제 합산 TOP 10."},{"name":"전역 탐색 (Region Explore)","description":"행정동 축으로 전역 공개 콘텐츠를 탐색하는 API — 지도 홈 패널·전체 보기 격자 썸네일 뷰·검색 무입력 전체 지역 리스트."},{"name":"관리자 신고 처리 (Admin Report)","description":"접수된 영상 신고의 열람·승인·기각과 블라인드 해제·단건 확인 API (MSG-195). ADMIN 권한 필수."},{"name":"사용자 (User)","description":"계정 관리 API. 인증 필수 — 본인 계정만 대상이다."},{"name":"뱃지 (Badge)","description":"뱃지 API — 내 뱃지 목록 조회 · 대표 뱃지 집합 교체."},{"name":"친구 (Friend)","description":"고정 친구 코드 기반 친구 관계 API — 코드·요청·수락·거절·삭제 (MSG-185), 친구 목록·친구 프로필 조회 (MSG-186), 친구 도감 레이어(격자 뷰포트·격자 영상 목록, MSG-187, 축소 시야의 행정 단위 집계는 MSG-356). 인증 필수."},{"name":"행사 (Events)","description":"지도 홈 행사 칩·이벤트 헤더·행사 위치 목록 조회 API."},{"name":"관리자 행사 운영자 계정 (Admin Org Account)","description":"계정 발급 요청 검토와 계정 발급·초기 비밀번호 재발송 API (MSG-499), 아이디 변경 요청 심사 (MSG-500). ADMIN 권한 필수."},{"name":"행사 등재 신청 (Org Submission)","description":"행사 운영자가 행사를 신청하고 반려본을 고쳐 다시 낸다."},{"name":"신고 (Report)","description":"영상 신고 접수 API (MSG-192). 인증 필수."},{"name":"행사 (Events)","description":"행사 영상의 댓글·도움돼요 API."},{"name":"사용자 차단 (User Block)","description":"다른 사용자를 차단·해제하고 내가 차단한 목록을 본다. 차단 관계에서는 서로의 영상·댓글이 보이지 않는다."},{"name":"핫구역 (HotZone)","description":"최근 48시간 방문(업로드) 신호 상위 격자 조회 API — 개인화 없는 공용 목록."}],"paths":{"/api/videos/{videoId}":{"get":{"tags":["영상 (Video)"],"summary":"단건 영상 재생 조회","description":"영상 하나의 표시용 메타와 재생본 presigned GET URL을 발급한다. 소유자·타인 모두 조회할 수 있으나 삭제·블라인드(타인)는 404, 비공개(타인)·친구만 공개(비친구)는 403이다. READY가 아니면 playbackUrl은 null이다. 비로그인도 조회할 수 있으며 전체 공개 영상만 통과한다 — 나머지는 타인이 요청할 때와 같은 응답으로 거절된다.","operationId":"getPlayback","parameters":[{"name":"videoId","in":"path","description":"재생할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoPlaybackResponseDto"}}}}}},"put":{"tags":["영상 (Video)"],"summary":"영상 교체","description":"기존 영상을 새 파일로 교체한다. 좌표를 생략하면 격자를 유지하고 파일만 교체하며, 좌표를 보내면 기존과 같은 격자여야 한다(다르면 거부). 교체 직후 상태는 UPLOADED다.","operationId":"replace","parameters":[{"name":"videoId","in":"path","description":"교체할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1001}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoReplaceRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoReplaceResponseDto"}}}}}},"delete":{"tags":["영상 (Video)"],"summary":"영상 삭제","description":"영상을 삭제한다. 해당 격자의 내 영상이 모두 사라지면 점령이 롤백(색칠 해제)된다.","operationId":"delete","parameters":[{"name":"videoId","in":"path","description":"삭제할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1001}],"responses":{"200":{"description":"OK"}}}},"/api/users/me/profile-image":{"put":{"tags":["사용자 (User)"],"summary":"프로필 이미지 변경 확정","description":"presign 으로 올린 pending 키를 확정해 프로필 이미지를 교체하고 갱신된 프로필을 반환한다. 내 pending 경로가 아니거나 확장자 없는 키는 1401, S3 에 실제로 없는 키는 1402, 실측 크기가 5MB 를 넘으면 1413. 교체된 이전 이미지는 응답 후 정리된다.","operationId":"updateProfileImage","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileImageUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}},"delete":{"tags":["사용자 (User)"],"summary":"프로필 이미지 제거","description":"프로필 이미지를 기본 상태(null)로 되돌린다. 이미 기본 상태여도 성공한다(멱등). 응답은 변경 확정과 같은 프로필 형태다.","operationId":"removeProfileImage","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/nickname":{"put":{"tags":["사용자 (User)"],"summary":"닉네임 수정","description":"닉네임(2~20자)을 교체하고 변경 후 프로필을 반환한다. 중복 닉네임은 허용된다.","operationId":"updateNickname","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NicknameUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/marketing-consent":{"put":{"tags":["사용자 (User)"],"summary":"마케팅 정보 수신 동의 변경","description":"가입 후 설정 화면에서 마케팅 수신 동의를 켜거나 끈다. 이미 저장된 값과 같은 값을 다시 보내도 성공하며, 이때 서버가 보관하는 마지막 변경 시각은 갱신되지 않는다(멱등). 응답은 변경 후 동의 상태다 — 위치정보 사용 동의 변경과 같은 구조다.","operationId":"updateMarketingConsent","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketingConsentUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}}},"/api/users/me/location-consent":{"put":{"tags":["사용자 (User)"],"summary":"위치정보 사용 동의 켜기","description":"위치기반서비스 이용 동의를 켜고 변경 후 프로필을 반환한다. 첫 로그인 온보딩의 동의 제출과 프로필 화면이 이 엔드포인트 하나를 공용으로 쓴다.\n\n이 동의는 철회할 수 없다 — consented=false 요청은 1400 으로 거절된다. 되돌리려면 계정을 삭제해야 하며, 이는 다른 필수 약관 동의와 같은 규칙이다. 이미 켜진 상태에서 다시 켜는 요청은 성공하고, 이때 서버가 보관하는 동의 시각은 갱신되지 않는다(멱등).","operationId":"updateLocationConsent","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LocationConsentUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}}},"/api/users/me/consents":{"get":{"tags":["사용자 (User)"],"summary":"가입 약관 동의 상태 조회","description":"로그인 직후 동의 게이트를 띄울지 판별하는 재료다. 항목별 동의 여부 5종과 필수 4항목 완료 여부(requiredCompleted)를 함께 반환한다 — 필수 항목 목록이 늘어도 클라이언트가 조립을 고치지 않도록 서버가 계산한다.\n\n위치기반서비스 항목(locationTerms)은 프로필 화면의 위치정보 사용 동의와 같은 한 값이다. 이 동의는 철회할 수 없으므로 한 번 true 가 되면 되돌아가지 않고, 필수 동의를 마친 사용자에게 게이트가 다시 뜨는 일도 없다. 동의 시각은 서버에만 보관하고 응답에 싣지 않는다.","operationId":"getConsentStatus","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}},"put":{"tags":["사용자 (User)"],"summary":"가입 약관 동의 제출","description":"가입 게이트의 \"동의하고 시작하기\" 제출이다. 필수 4항목(만 14세 이상·서비스 이용약관·개인정보 수집·이용·위치기반서비스 이용약관)은 true 여야 하고 마케팅만 선택이다 — 하나라도 false 거나 누락이면 400 이며 이때 아무 항목도 저장되지 않는다.\n\n같은 내용을 다시 보내도 성공한다(멱등). 재제출이 필수 4항목의 최초 동의 시각을 덮지 않고, 마케팅만 값이 실제로 달라질 때 변경 시각이 갱신된다. 제출은 위치정보 사용 동의도 함께 켜므로 프로필 화면의 위치 동의와 값이 하나다. 응답은 제출 후 동의 상태다.","operationId":"submitConsents","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsentSubmitRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoConsentStatusResponseDto"}}}}}}},"/api/event-videos/{videoId}/helpful":{"put":{"tags":["행사 (Events)"],"summary":"행사 영상 도움돼요 추가","description":"이 영상에 도움돼요를 누른다. 사용자당 한 번이고 이미 누른 상태에서 다시 불러도 성공하며 수가 늘지 않는다 — 네트워크 재시도가 수를 흔들지 않도록 PUT 으로 둔 이유다.\n\n응답의 helpfulCount 는 처리 후 다시 센 값이라 그 사이 다른 사람이 누른 것도 반영된다.\n\n아카이브된 행사(종료 30일 후)에서는 409 + developCode 13422 다 — 유예 기간까지는 계속 누를 수 있다. 상세에 보이지 않는 영상은 404 + 13406 이다.","operationId":"addHelpful","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoHelpfulResponseDto"}}}}}},"delete":{"tags":["행사 (Events)"],"summary":"행사 영상 도움돼요 취소","description":"누른 도움돼요를 되돌린다. 누른 적이 없어도 실패하지 않는다(멱등).\n\n아카이브된 행사(종료 30일 후)에서는 409 + developCode 13422 다 — 유예 기간까지는 취소할 수 있다. 상세에 보이지 않는 영상은 404 + 13406 이다.","operationId":"removeHelpful","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoHelpfulResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/notification":{"put":{"tags":["행사 (Events)"],"summary":"행사 알림 구독 토글","description":"행사 회차 단위로 알림을 켜고 끈다. 이벤트에는 참여 절차가 없어 구독이 사용자와 행사가 맺는 관계의 전부다. 같은 값을 반복 요청해도 같은 결과로 성공한다.\n\n응답의 enabled 는 저장된 구독 행의 존재가 아니라 **노출 상태**다 — 구독 행이 있으면서 회차가 예정이거나 진행 중일 때만 true 이고, 종료된 회차는 행이 남아 있어도 false 다(종료 시점부터 즉시 OFF, 정리 배치를 기다리지 않는다).\n\n종료된 행사(업로드 유예·아카이브)에 켜기를 요청하면 409 + developCode 13422 다 — 시작 알림이 이미 지나 받을 것이 없기 때문이다. 끄기는 상태와 무관하게 언제나 성공한다. 없는 회차이거나 아직 노출 기간 전인 예정 회차면 404 + developCode 13404 다.\n\n실제 발송은 이 구독 위에 알림 설정의 EVENT 카테고리 스위치가 겹쳐 결정된다 — 카테고리를 끈 사용자에게는 구독이 켜져 있어도 발송되지 않는다.","operationId":"updateSubscription","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventNotificationUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventNotificationResponseDto"}}}}}}},"/api/badges/featured":{"put":{"tags":["뱃지 (Badge)"],"summary":"대표 뱃지 집합 교체","description":"획득한 뱃지 중 최대 2개를 대표로 교체 지정한다(멱등). 배열 순서 = 표시 순서(rank 1·2), 빈 배열은 전부 해제. 미획득·미존재 뱃지는 7403, 중복 id 는 7400, 3개 이상은 400 이다.","operationId":"replaceFeatured","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeaturedBadgeRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFeaturedBadgeResponseDto"}}}}}}},"/api/videos":{"post":{"tags":["영상 (Video)"],"summary":"영상 메타데이터 저장 (업로드 확정)","description":"S3 업로드 완료 후 영상 메타데이터를 저장하고 좌표로 격자를 매핑한다. 해당 격자에 내 첫 영상이면 점령(occupied=true)된다.","operationId":"upload","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoUploadResponseDto"}}}}}}},"/api/videos/{videoId}/reports":{"post":{"tags":["신고 (Report)"],"summary":"영상 신고 접수","description":"다른 사람의 영상을 사유 5종(INAPPROPRIATE, PRIVACY, SPAM, COPYRIGHT, OTHER) 중 하나와 함께 신고한다. 접수된 신고는 PENDING 으로 쌓여 관리자 처리의 입력이 되며, 접수 자체는 영상 상태를 바꾸지 않는다. 같은 영상 재신고는 409, 자기 영상 신고는 400, 없는 영상·삭제·블라인드 영상은 재생 조회와 같은 404 다.","operationId":"report","parameters":[{"name":"videoId","in":"path","description":"신고할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoReportCreateResponseDto"}}}}}}},"/api/videos/presigned-url":{"post":{"tags":["영상 (Video)"],"summary":"업로드용 presigned URL 발급","description":"영상 파일을 S3에 직접 올릴 presigned URL을 발급한다. 이 URL로 PUT 업로드한 뒤 메타데이터 저장을 호출한다.","operationId":"issuePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUrlRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoPresignedUrlResponseDto"}}}}}}},"/api/videos/highlight-preview":{"post":{"tags":["영상 (Video)"],"summary":"하이라이트 선분석","description":"업로드 확정 전 원본(presign purpose=HIGHLIGHT_PREVIEW 로 올린 pending 키)의 AI 하이라이트 구간을 동기로 계산해 돌려준다. 원본 길이에 따라 응답까지 수 초에서 수십 초 걸린다(30초 1080p 기준 5초 내외). highlights 가 빈 배열이면 추천 없음이니 FE 는 추천 단계를 스킵한다. 실패 시 FE 는 직접 구간 지정으로 폴백한다 — 3502(분석 서버 문제, 재시도 가능)·3426(원본 파일 불량, 재시도 무의미)·3425(3분 초과)·3413(400, 허용 크기 초과). 결과는 저장되지 않는 임시 값이며, 같은 키로 이후 업로드 확정(POST /api/videos)이 가능하다.","operationId":"highlightPreview","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HighlightPreviewRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoHighlightPreviewResponseDto"}}}}}}},"/api/users/{userId}/block":{"post":{"tags":["사용자 차단 (User Block)"],"summary":"사용자 차단","description":"경로의 사용자를 차단한다. 차단하면 두 사람 사이의 친구 관계(수락됨·대기 중, 방향 무관)가 함께 삭제되고, 이후 서로의 영상과 댓글이 목록·재생·상세에서 보이지 않는다. 이미 차단한 사용자를 다시 차단해도 성공하며 최초 차단 시각이 유지된다(멱등). 자기 자신은 400 + 1430, 존재하지 않는 사용자는 404 + 1404 다. 신고는 차단과 무관하게 계속 할 수 있다.","operationId":"block","parameters":[{"name":"userId","in":"path","description":"차단할 사용자 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":42}],"responses":{"200":{"description":"OK"}}},"delete":{"tags":["사용자 차단 (User Block)"],"summary":"사용자 차단 해제","description":"내가 걸은 차단을 푼다. 차단한 적 없는 사용자나 존재하지 않는 userId 도 200 이다(멱등). 차단으로 삭제된 친구 관계는 되살아나지 않는다. 상대가 나를 차단한 행은 그대로라 그 경우 서로의 콘텐츠는 계속 보이지 않는다.","operationId":"unblock","parameters":[{"name":"userId","in":"path","description":"차단을 해제할 사용자 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":42}],"responses":{"200":{"description":"OK"}}}},"/api/users/me/profile-image/presigned-url":{"post":{"tags":["사용자 (User)"],"summary":"프로필 이미지 업로드용 presigned URL 발급","description":"프로필 이미지를 S3 에 직접 올릴 presigned URL 을 발급한다. 이 URL 로 PUT 업로드한 뒤 받은 s3Key 로 변경 확정(PUT /api/users/me/profile-image)을 호출한다. 허용 형식은 jpg·jpeg·png·webp 이고 크기 상한은 5MB 다 — 확장자와 Content-Type 이 어긋나거나 허용 밖이면 1415, 선언 크기가 상한을 넘으면 1413.\n\n아이폰 사진(heic·heif)은 받지 않는다 — 저장해도 대부분의 브라우저가 표시하지 못하기 때문이다. 파일 선택 accept 목록에서 heic 를 빼면 iOS 가 플랫폼 수준에서 JPEG 로 변환해 주므로 정상 경로에서는 거부가 나오지 않고, 그래도 새어 들어온 원본 heic 는 1415 응답을 안내 문구로 처리한다.","operationId":"issueProfileImagePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileImagePresignRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoProfileImagePresignResponseDto"}}}}}}},"/api/routes/walk-paths":{"post":{"tags":["AI 경로 추천 (Routes)"],"summary":"세그먼트 보행 경로 조회","description":"추천 응답의 이웃 좌표쌍(출발지 구간 포함 1~8개)을 보내면 서버가 TMap 보행자 경로안내를 대신 호출해 세그먼트별 보행 좌표열과 실거리(미터)를 요청과 같은 개수, 같은 순서로 돌려준다.\n\nTMap 호출 실패·형태 위반·일 한도 소진은 에러가 아니라 200 에 해당 세그먼트 resolved: false 다 — 그 세그먼트는 직선과 직선거리 안내를 유지하면 된다 (부분 실패 허용).\n\n목록이 없거나 비었거나 9개 이상, 원소가 null, 좌표가 한국 서비스 범위(위도 33~39·경도 124~132) 밖이면 400 + developCode 14402 이고, 기능이 꺼진 환경(route.walk.enabled=false)에서는 503 + 14504 다.","operationId":"walkPaths","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteWalkPathRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRouteWalkPathResponseDto"}}}}}}},"/api/routes/recommend":{"post":{"tags":["AI 경로 추천 (Routes)"],"summary":"AI 경로 추천","description":"자연어 한 문장과 지금 보는 지도 범위를 보내면 서버 보유 후보(활성 미션·행사·장소 검색)에서 골라 방문 순서를 붙인 지점 목록(최대 8개)을 돌려준다. 지점마다 추천 이유 한 줄이 실린다.\n\n후보가 0~2개면 실패가 아니라 찾은 만큼과 notice 안내가 함께 오는 성공이다.\n\nviewport 가 뒤집혔거나 넓이 0 이거나 범위 밖이면 400 + developCode 14400, 한 변이 0.5도를 넘으면 400 + 14401 이다. 같은 사용자의 직전 시도 후 10초 안 재요청은 429 + 14429. AI 해석 실패는 502 + 14502 이고, 기능이 꺼진 환경(route.ai.enabled=false)에서는 503 + 14503 이다.","operationId":"recommend","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteRecommendRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRouteRecommendResponseDto"}}}}}}},"/api/org/event-submissions":{"post":{"tags":["행사 등재 신청 (Org Submission)"],"summary":"행사 등재 신청 제출","description":"심사 중 상태로 접수하고 신청 번호(FM-2026-XXXX 꼴)를 부여한다. 위치마다 대표 격자를 서버가 계산해 저장하며, 위치 하나의 영역은 겹침을 한 번만 세는 합집합 기준 최대 81칸이다.\n\n유형별 필수 항목이 다르다 — FESTIVAL 은 주요 프로그램, POPUP 은 운영 시간, EVENT 는 참여 방식과 참여할 승인 이벤트 회차(parentOccurrenceId)이고 자기 유형이 아닌 항목이 실려 오면 거부한다. EVENT 의 위치는 대표 위치 정확히 1곳이고, 참여할 회차가 이미 끝났으면 접수하지 않는다.\n\n위치에는 이름 필드가 없고 배열 순서가 곧 순번이다.","operationId":"submit","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventSubmissionCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionSubmitResponseDto"}}}}}}},"/api/org/event-submissions/image/presigned-url":{"post":{"tags":["행사 등재 신청 (Org Submission)"],"summary":"대표 이미지 presigned URL 발급","description":"받은 uploadUrl 로 S3 에 직접 PUT 업로드한 뒤, 응답의 s3Key 를 신청 제출·재제출 요청의 imageS3Key 로 넘긴다. jpg·jpeg·png 만 받고 상한은 10MB 다.","operationId":"issueImagePresignedUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventSubmissionImagePresignRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionImagePresignResponseDto"}}}}}}},"/api/org/email-change-request":{"post":{"tags":["행사 운영자 계정 (Org Account)"],"summary":"아이디 변경 요청","description":"아이디(공식 이메일)는 기관 인증의 근거라 자체 변경이 불가하다. 이 API 는 변경 요청을 접수만 하고, 관리자가 승인해야 실제로 바뀐다.\n\n대기 중인 요청이 있으면 그 요청이 새 값으로 갱신된다 — 마지막 요청이 유효하다.","operationId":"requestEmailChange","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgEmailChangeRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/org-account-requests":{"post":{"tags":["행사 운영자 계정 발급 요청 (Org Account Request)"],"summary":"계정 발급 요청 접수","description":"계정 발급 신청을 대기 상태로 접수한다. 관리자가 큐에서 검토해 승인하면 계정이 만들어지고 초기 비밀번호가 공식 이메일로 발송된다.\n\n같은 공식 이메일의 대기 요청이 이미 있으면 그 요청이 새 내용으로 갱신된다 — 마지막 접수가 유효하므로 더블클릭 재제출과 오타 정정 재접수가 한 건으로 수렴한다. 신청 번호는 부여하지 않는다.","operationId":"create","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgAccountRequestCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/notifications/tokens":{"post":{"tags":["알림 (Notification)"],"summary":"FCM 토큰 등록/갱신","description":"디바이스의 FCM 토큰을 현재 계정으로 등록한다(UPSERT). 같은 토큰 재등록은 충돌 없이 user_id·platform·appVersion·last_used_at 이 갱신된다 — 재로그인·계정 전환 포함. platform 이 IOS/ANDROID/WEB(대소문자 무시) 외면 10400 이다.","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushTokenRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}},"delete":{"tags":["알림 (Notification)"],"summary":"FCM 토큰 해제","description":"본인 소유(user_id 일치) 토큰 행을 삭제한다 — 멱등, 없는 토큰·소유 불일치 해제도 200. 로그아웃은 /api/auth/logout body 의 fcmToken 으로 한 번에 처리하고, 이 API 는 토큰 로테이션 등 로그아웃 외 정리 용도다.","operationId":"unregister","parameters":[{"name":"fcmToken","in":"query","description":"해제할 FCM 토큰","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/missions/{missionId}/videos":{"get":{"tags":["미션 영상 (Mission Videos)"],"summary":"미션 영상 목록 조회","description":"그 미션의 대상 격자에서 미션 기간에 촬영된 공개(PUBLIC)·READY 영상을 촬영 시각(recordedAt) 최신순으로 페이지 조회한다 — 촬영 시각이 같으면 videoId 내림차순으로 갈린다. 기간이 없는 미션(코스·지속형)은 기간 조건 없이 과거 영상까지 담고, 기간이 끝난 미션도 목록은 그대로 조회된다. 비공개·친구 공개·삭제·블라인드·인코딩 미완 영상은 본인 것이라도 제외된다. 로그인 요청이면 요청자와 차단 관계(어느 방향이든)인 작성자의 영상도 빠지고, 그 밖에는 응답이 누가 부르든 같다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 커서는 발급된 그 미션 전용이라 다른 미션 커서는 400(INVALID_CURSOR)이고, 형식이 깨진 커서도 같다. size 는 1~50 밖이면 클램프된다. 조건에 맞는 영상이 없거나 존재하지 않는 missionId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getMissionVideos","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor (opaque). 생략하면 첫 페이지","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridVideoPageResponseDto"}}}}}},"post":{"tags":["미션 영상 (Mission Videos)"],"summary":"미션 경유 영상 업로드 확정","description":"축제·팝업 미션에 영상을 올린다. 파일은 기존 presigned 발급(POST /api/videos/presigned-url)으로 S3 에 먼저 올리고 이 API 가 확정한다.\n\n좌표도 격자도 받지 않는다. 저장 위치는 서버가 그 미션의 대표 격자로 정하므로 같은 미션의 영상이 지도에서 한 칸에 모인다. 공개 범위는 PUBLIC 으로 고정되고, 업로드는 일반 업로드와 똑같이 그 격자의 점령을 만들며 뱃지·스트릭·미션 스탬프도 그대로 반영된다.\n\n같은 s3Key 로 다시 보내면 영상이 하나 더 생기지 않고 저장된 행 기준의 성공이 돌아온다. 이때 occupied 는 false, newBadges 와 completedMissions 는 빈 배열이다(첫 응답 전용 필드).\n\n촬영 시각이 미래면 400 + developCode 3424, 키 형식이 아니거나 남의 pending 키면 400 + 3401 이다. 그 밖의 모든 실패는 409 + 12409 하나로 돌아온다 — 없는 미션, 코스처럼 대상이 아닌 유형, 기간 밖, 촬영 시각이 미션 기간 밖, 대표 격자가 없는 미션, 이미 다른 자리에 쓴 키, S3 에 없는 키가 전부 여기 해당하며 사유는 갈라 주지 않는다. 이 응답을 받으면 그대로 재시도하지 말고 미션 상세를 다시 불러 업로드 가능 여부를 확인하고, 미션이 여전히 열려 있으면 presigned URL 을 새로 발급받아 파일부터 다시 올린다.\n\n인코딩이 끝나기 전에는 목록에 잡히지 않는다 — 업로드 직후 화면에 카드를 보여주려면 이 응답으로 낙관적으로 그린다(기존 업로드와 같은 성질).","operationId":"uploadMissionVideo","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MissionVideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoMissionVideoUploadResponseDto"}}}}}}},"/api/friends/requests":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청","description":"상대의 친구 코드로 요청을 보낸다. 응답 status 가 PENDING 이면 상대 수락 대기, ACCEPTED 면 상대가 먼저 보낸 요청이 있어 즉시 친구 성립(자동 수락)이다.","operationId":"request","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FriendRequestCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendRequestCreateResponseDto"}}}}}}},"/api/friends/requests/{requesterId}/reject":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청 거절","description":"받은 요청을 거절한다. 보낸 쪽에 통지는 없고, 상대는 다시 요청할 수 있다.","operationId":"reject","parameters":[{"name":"requesterId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}},"/api/friends/requests/{requesterId}/accept":{"post":{"tags":["친구 (Friend)"],"summary":"친구 요청 수락","description":"받은 요청을 수락해 친구 관계를 성립시킨다. 요청의 수신자 본인만 가능하다.","operationId":"accept","parameters":[{"name":"requesterId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}},"/api/event-videos/{videoId}/comments":{"get":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 목록 조회","description":"영상에 달린 댓글을 오래된 순으로 한 페이지 돌려준다 — 새 댓글이 아래에 쌓이는 배열이다.\n\n영상 상세가 첫 페이지(20건)를 이미 품고 있으므로 이 API 는 둘째 페이지부터를 위한 것이다. cursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 영상 목록에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.\n\n아카이브된 행사에서도 조회할 수 있고 댓글이 없으면 실패가 아니라 빈 페이지다. 비로그인으로도 조회할 수 있다. 로그인 요청이면 영상 작성자와 차단 관계(어느 방향이든)일 때 상세와 같은 404 + 13406 이고, 차단 관계인 작성자의 댓글은 목록에서 빠진다(댓글 수는 그대로다).","operationId":"getComments","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentPageResponseDto"}}}}}},"post":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 작성","description":"행사 영상에 댓글을 단다. 내용은 1~500자다.\n\n이벤트가 아카이브로 넘어가면 댓글을 더 달 수 없다 — 종료 30일 후부터 409 + developCode 13422 다(기존 댓글은 계속 보인다). 그 전까지는 예정·진행 중은 물론 유예 기간(종료 후 30일)에도 쓸 수 있고, 유예 기간에 새로 올라온 영상에도 댓글을 남길 수 있다.\n\n상세에 보이는 영상에만 쓸 수 있다 — 삭제·블라인드·비공개·처리 미완료 영상과 행사 영상이 아닌 영상 id 는 올린 본인에게도 404 + 13406 이다.","operationId":"createComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoCommentRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/locations/{locationId}/videos":{"get":{"tags":["행사 (Events)"],"summary":"위치별 영상 피드 조회","description":"행사 위치에 올라온 영상을 최신 업로드순으로 한 페이지 돌려준다. 영역 안 어느 격자를 눌러 들어와도 같은 위치의 같은 피드다.\n\n여기 담기는 영상은 위치 목록의 영상 수와 정확히 같은 집합이다 — 삭제·비공개·처리 미완료 영상은 숫자에서도 목록에서도 함께 빠진다. 인코딩이 끝나기 전 영상은 아직 담기지 않는다.\n\ncursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 위치 피드에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.\n\n아카이브된 행사에서도 조회할 수 있고 영상이 없으면 실패가 아니라 빈 페이지다. 존재하지 않거나 노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다. 비로그인으로도 조회할 수 있다. 로그인 요청이면 요청자와 차단 관계(어느 방향이든)인 작성자의 영상은 빠지고(위치 카드의 영상 수는 그대로다), 항목의 uploaderId 는 작성자 식별자라 차단(POST /api/users/{userId}/block)의 경로 값으로 쓴다.","operationId":"getLocationVideos","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"locationId","in":"path","description":"행사 위치 id","required":true,"schema":{"type":"integer","format":"int64"},"example":34},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventLocationVideoPageResponseDto"}}}}}},"post":{"tags":["행사 (Events)"],"summary":"행사 영상 업로드 확정","description":"행사 위치에 영상을 올린다. 파일은 기존 presigned 발급(POST /api/videos/presigned-url)으로 S3 에 먼저 올리고 이 API 가 확정한다 — 촬영이든 갤러리 선택이든 서버 계약은 하나다.\n\n좌표를 받지 않는다. 격자는 서버가 그 위치의 대표 격자로 정하므로 현장에 없어도 올릴 수 있고, 공개 범위는 PUBLIC 으로 고정된다. 업로드는 일반 업로드와 똑같이 그 격자의 점령을 만들고 뱃지·스트릭도 그대로 반영된다(미션만 연계되지 않는다).\n\n같은 s3Key 로 다시 보내면 영상이 하나 더 생기지 않고 저장된 행 기준의 성공이 돌아온다. 이때 occupied 는 false, newBadges 는 빈 배열이다(첫 응답 전용 필드).\n\n올릴 수 있는 기간은 행사 시작부터 종료 30일 후 직전까지다. 시작 전이면 409 + developCode 13410, 마감 이후면 409 + 13409 다. 존재하지 않거나 아직 노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다.\n\n인코딩이 끝나기 전에는 피드에 잡히지 않는다 — 업로드 직후 화면에 카드를 보여주려면 이 응답으로 낙관적으로 그린다(기존 업로드와 같은 성질).","operationId":"upload_1","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12},{"name":"locationId","in":"path","description":"행사 위치 id","required":true,"schema":{"type":"integer","format":"int64"},"example":34}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoUploadRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoUploadResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/heartbeat":{"post":{"tags":["이벤트 (Event)"],"summary":"열람 heartbeat","description":"이벤트를 보는 동안 30초 주기로 보낸다. 마지막 신호가 90초 이내인 세션만 열람 인원에 센다. 비로그인은 X-Viewer-Session 헤더(공백 아님·최대 64자) 필수 — 없으면 400. 캐시 장애는 삼켜져 200 이다.","operationId":"heartbeat","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"X-Viewer-Session","in":"header","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/auth/signup":{"post":{"tags":["인증 (Auth)"],"summary":"이메일 회원가입","description":"이메일/비밀번호/닉네임으로 신규 회원을 생성한다.","operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoSignupResponseDto"}}}}}}},"/api/auth/reissue":{"post":{"tags":["인증 (Auth)"],"summary":"토큰 재발급","description":"리프레시 토큰(웹=쿠키, 앱=body)으로 새 액세스 토큰과 회전된 새 리프레시 토큰을 발급받는다. 직전 리프레시 토큰은 즉시 무효화되며, 회전된 옛 토큰 재사용 시 세션 체인이 폐기된다. 쿠키로 리프레시를 보내는 웹은 CSRF 방어를 위해 X-Client-Type 헤더가 필수다(없으면 400). body 로 보내는 앱은 생략할 수 있다.","operationId":"reissue","parameters":[{"name":"refreshToken","in":"cookie","required":false,"schema":{"type":"string"}},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app). 리프레시를 쿠키로 보내면 필수, body 로 보내면 생략 가능(생략 시 web 취급).","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReissueRequestDto"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoReissueResponseDto"}}}}}}},"/api/auth/password/reset":{"post":{"tags":["비밀번호 (Password)"],"summary":"비밀번호 재설정 확정","description":"메일 링크의 토큰으로 새 비밀번호를 설정한다. 토큰은 한 번만 쓸 수 있다.\n\n성공하면 그 계정의 모든 기기 로그인이 끊기고, 이미 발급돼 있던 액세스 토큰도 즉시 무효가 된다 — 비밀번호를 잊은 복구 흐름이라 기존 세션을 남기지 않는다.","operationId":"resetPassword","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetConfirmRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/auth/password/reset-request":{"post":{"tags":["비밀번호 (Password)"],"summary":"비밀번호 재설정 링크 요청","description":"공식 이메일로 30분 동안 유효한 재설정 링크를 보낸다. 재요청하면 이전 링크는 즉시 무효가 된다.\n\n계정이 있든 없든 항상 같은 성공 응답이다 — 이 API 로 가입 여부를 알아낼 수 없게 하기 위해서다.","operationId":"requestReset","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/auth/password/initial":{"post":{"tags":["비밀번호 (Password)"],"summary":"초기 비밀번호 설정","description":"관리자가 발급한 초기 비밀번호로 처음 로그인한 계정이 현재 비밀번호 입력 없이 새 비밀번호만으로 설정을 마친다. 성공하면 강제 변경 상태가 풀려 콘솔이 열리고, 남아 있던 재설정 링크는 폐기된다. 로그인 중인 세션은 그대로 유지된다.\n\n이미 설정을 마친 계정은 2446 으로 거절되니 비밀번호 변경(/change) 화면으로 안내하면 된다. 소셜 로그인 계정은 2445 로 거절된다.","operationId":"setInitialPassword","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordInitialRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/auth/password/change":{"post":{"tags":["비밀번호 (Password)"],"summary":"비밀번호 변경","description":"현재 비밀번호를 확인하고 새 비밀번호로 바꾼다. 성공하면 강제 변경 상태가 풀려 콘솔이 열리고, 남아 있던 재설정 링크는 폐기된다. 로그인 중인 다른 기기의 세션은 그대로 유지된다.\n\n이메일·비밀번호로 만든 계정만 쓸 수 있다 — 소셜 로그인 계정은 2445 로 거절된다.","operationId":"changePassword","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordChangeRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["인증 (Auth)"],"summary":"소셜 로그인 (OIDC)","description":"소셜 제공자의 ID Token으로 로그인/가입하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키(Set-Cookie)로 내려가 body 의 refreshToken 이 null 이고, 앱(app)은 body 로 내려간다. provider=apple 은 nonce 원문과 authorizationCode 가 필수이고(첫 로그인에서만 애플 토큰 교환), fullName 은 계정 생성 때만 닉네임으로 쓴다.","operationId":"oauthLogin","parameters":[{"name":"provider","in":"path","description":"소셜 제공자 (KAKAO|APPLE)","required":true,"schema":{"type":"string"},"example":"KAKAO"},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OidcLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/oauth/kakao/code":{"post":{"tags":["인증 (Auth)"],"summary":"소셜 로그인 (카카오 인가 코드)","description":"웹에서 카카오 콜백으로 받은 인가 코드로 로그인/가입한다. 서버가 REST API 키로 카카오 토큰 엔드포인트를 호출해 ID Token 을 받은 뒤, 소셜 로그인(OIDC)과 완전히 같은 검증·발급 경로를 태운다. 인가 진입점이 심은 OAUTH_NONCE 쿠키가 함께 와야 한다(없으면 401). 응답 형태는 기존 소셜 로그인과 동일하다 — 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키(Set-Cookie)로 내려가 body 의 refreshToken 이 null 이고, 앱(app)은 body 로 내려간다. 네이티브 SDK 가 교환까지 해주는 앱은 이 API 가 아니라 POST /api/auth/oauth/{provider} 를 쓴다.","operationId":"oauthCodeLogin","parameters":[{"name":"OAUTH_NONCE","in":"cookie","required":false,"schema":{"type":"string"}},{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KakaoCodeLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/logout":{"post":{"tags":["인증 (Auth)"],"summary":"로그아웃","description":"Authorization 헤더의 액세스 토큰을 무효화하고 해당 디바이스(X-Device-Id)의 리프레시 세션을 삭제한다. X-Device-Id 가 없으면 해당 유저의 모든 디바이스 세션을 삭제한다. 선택 body 의 fcmToken 이 있으면 해당 FCM 푸시 토큰도 함께 정리된다 (MSG-178 logout 통합).","operationId":"logout","parameters":[{"name":"Authorization","in":"header","required":false,"schema":{"type":"string"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 모든 디바이스 세션 삭제(로그아웃-올).","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutRequestDto"}}}},"responses":{"200":{"description":"OK"}}}},"/api/auth/login":{"post":{"tags":["인증 (Auth)"],"summary":"이메일 로그인","description":"이메일/비밀번호로 로그인하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키로, 앱(app)은 body 로 내려간다.","operationId":"login","parameters":[{"name":"X-Client-Type","in":"header","description":"클라이언트 유형 (web|app, 기본 web)","required":false,"schema":{"type":"string","default":"web"}},{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환한다.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/auth/dev/social-login":{"post":{"tags":["인증-개발용 (Auth Dev)"],"summary":"[개발용] 소셜 로그인 모의","description":"실제 OIDC ID Token 검증 없이 (provider, oid)로 사용자를 find-or-create 하고 액세스+리프레시 토큰을 발급한다. 리프레시는 body 로 내려간다(앱 모드). 로컬/dev 프로파일에서만 노출.","operationId":"socialLogin","parameters":[{"name":"X-Device-Id","in":"header","description":"디바이스 식별자. 없으면 서버가 UUID 를 생성해 응답 헤더 X-Device-Id 로 반환.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevSocialLoginRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoLoginResponseDto"}}}}}}},"/api/admin/videos/{videoId}/unblind":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"블라인드 해제","description":"BLINDED 영상을 ACTIVE 로 복구한다. 오판 복구용이며 그 신고의 RESOLVED 는 되돌리지 않는다. 없는 영상과 삭제된 영상은 404(3404), 이미 ACTIVE 면 409(3409) 다.","operationId":"unblindVideo","parameters":[{"name":"videoId","in":"path","description":"해제할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminVideoUnblindResponseDto"}}}}}}},"/api/admin/reports/{reportId}/reject":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 기각","description":"신고를 REJECTED 로 종결한다. 영상에는 아무 영향이 없고 응답의 videoStatus 는 현재 상태 그대로다. 없는 신고는 404(11404), 이미 처리된 신고는 409(11410) 다.","operationId":"reject_1","parameters":[{"name":"reportId","in":"path","description":"기각할 신고 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportProcessResponseDto"}}}}}}},"/api/admin/reports/{reportId}/approve":{"post":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 승인","description":"신고를 RESOLVED 로 종결하고 대상 영상을 블라인드한다 — 한 트랜잭션이다. 영상이 이미 BLINDED 거나 DELETED 면 영상 전이 없이 신고만 종결하며, 응답의 videoStatus 로 구분할 수 있다. 없는 신고는 404(11404), 이미 처리된 신고와 동시 처리의 늦은 쪽은 409(11410) 다.","operationId":"approve","parameters":[{"name":"reportId","in":"path","description":"승인할 신고 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportProcessResponseDto"}}}}}}},"/api/admin/organizations":{"get":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"발급된 행사 운영자 계정 목록","description":"발급 최신순으로 계정을 조회한다. 목록은 이 발급 경로가 만드는 형태(역할 ORG · 제공자 LOCAL) 만 담는다 — 재발송 대상 식별과 직접 발급 복구 확인이 목적이라서다.\n\n각 항목의 mustChange 가 화면의 사용 중 / 초기 로그인 전 라벨이다(false 가 사용 중). email 을 주면 완전 일치 검색이고, page 음수나 size 범위(1~100) 밖은 400(1425) 이다.","operationId":"getAccounts","parameters":[{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20},{"name":"email","in":"query","description":"공식 이메일 완전 일치 필터 (선택)","required":false,"schema":{"type":"string"},"example":"event@busanjin.go.kr"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminOrgAccountListResponseDto"}}}}}},"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"행사 운영자 계정 직접 발급","description":"공문으로 먼저 확인된 기관에 발급 요청 없이 계정을 만들고 초기 비밀번호를 발송한다. 결과는 승인과 같다.\n\n응답을 받지 못했으면 같은 요청을 재시도한다. 1409(이미 존재)가 오면 그 이메일로 계정이 있다는 뜻일 뿐 발급 성공의 증거가 아니므로, 계정 목록의 email 검색으로 가른다 — 결과가 있으면 발급된 것이고, 없으면 다른 계정과의 이메일 충돌이라 기관에 다른 공식 이메일을 요청한다.\n\n이미 계정이 있는 이메일은 409(1409) 다.","operationId":"issueDirect","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgAccountCreateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgAccountIssueResponseDto"}}}}}}},"/api/admin/organizations/{userId}/resend-password":{"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"초기 비밀번호 재발송","description":"새 초기 비밀번호를 만들어 공식 이메일로 다시 보낸다 — 재발송은 재발급이다. 평문을 저장하지 않아 보냈던 비밀번호를 다시 보낼 수 없고, 이전 초기 비밀번호는 즉시 무효가 된다.\n\n대상은 아직 초기 로그인을 마치지 않은 행사 운영자 계정뿐이다. 이미 본인이 비밀번호를 바꾼 계정은 409(1423) 이며, 그 경우의 분실 복구는 비밀번호 재설정 흐름을 안내한다.\n\n없는 사용자는 404(1404) 다.","operationId":"resendPassword","parameters":[{"name":"userId","in":"path","description":"재발송 대상 계정 id","required":true,"schema":{"type":"integer","format":"int64"},"example":42}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgAccountResendResponseDto"}}}}}}},"/api/admin/org-account-requests/{requestId}/reject":{"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"계정 발급 요청 반려","description":"요청을 반려하고 사유를 저장한 뒤, 요청자의 공식 이메일로 반려 사유를 담은 안내 메일을 발송한다 (필맵 서식 HTML + 평문 대체본). 사유는 필수다. 발송 실패는 반려를 뒤집지 않고 emailSent:false 로만 드러나며, 그때는 저장된 사유로 수기 통보한다(재발송 API 없음).\n\n없는 요청은 404(1421), 이미 처리된 요청은 409(1422), 검토 이후 요청 내용이 바뀌었으면 409(1426) 다.","operationId":"reject_2","parameters":[{"name":"requestId","in":"path","description":"반려할 요청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgAccountRequestRejectRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgAccountRequestRejectResponseDto"}}}}}}},"/api/admin/org-account-requests/{requestId}/approve":{"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"계정 발급 요청 승인","description":"행사 운영자 계정을 만들고 초기 비밀번호를 공식 이메일로 발송한다. 응답에는 발송 성공 여부만 실리고 초기 비밀번호 평문은 어디에도 실리지 않는다.\n\n메일 발송이 실패해도 계정과 발급됨 상태는 유지되며 emailSent 가 false 로 온다 — 복구는 재발송 API 다. 응답 자체를 받지 못했으면 상세를 재조회해 ISSUED 인지 확인하고, 발송 확신이 없으면 재발송을 쓴다.\n\n없는 요청은 404(1421), 이미 처리된 요청과 동시 승인의 늦은 쪽은 409(1422), 검토 이후 요청 내용이 바뀌었으면 409(1426), 이미 계정이 있는 이메일은 409(1409) 다.","operationId":"approve_1","parameters":[{"name":"requestId","in":"path","description":"승인할 요청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgAccountRequestApproveRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgAccountIssueResponseDto"}}}}}}},"/api/admin/events/{submissionId}/unpublish":{"post":{"tags":["관리자 승인 행사 (Admin Approved Event)"],"summary":"행사 노출 중지","description":"승인된 행사의 지도 노출을 사유와 함께 중지한다. 중지하면 그 승인 미션이 지도 칩 목록·격자 선택·미션 상세·영상 목록·스탬프 판정·미션 경유 업로드에서 즉시 빠진다(재기동 불요). 알고 있는 missionId 로 여는 상세와 영상 목록도 없는 미션과 같은 404 가 된다.\n\n이미 완료한 사용자의 스탬프와 진행 기록은 그대로 남는다 — 중지는 노출을 끊는 것이지 기록을 회수하는 것이 아니다.\n\n사유는 신청 계정의 공식 이메일로 발송된다. 발송이 실패해도 중지는 유지되며 emailSent 가 false 로 온다 — 저장된 사유가 수기 재통지의 재료이고 재발송 API 는 없다. 중지 해제(재노출)도 이 티켓 범위 밖이다.\n\n없거나 승인되지 않은 신청은 404(13430), 이미 중지된 행사는 409(13453) 다.","operationId":"unpublish","parameters":[{"name":"submissionId","in":"path","description":"중지할 승인 행사 식별자 (= 신청 id)","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminEventUnpublishRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminEventUnpublishResponseDto"}}}}}}},"/api/admin/event-submissions/{submissionId}/reject":{"post":{"tags":["관리자 행사 등재 심사 (Admin Event Submission)"],"summary":"신청 반려","description":"신청을 반려하고 항목 코드와 사유를 이력에 남긴다. 항목 코드는 1개 이상이어야 하고 PERIOD·AREA·IMAGE·INFO 만 쓸 수 있으며 중복은 허용하지 않는다. 사유 본문도 필수다.\n\n메일은 발송되지 않는다 — 행사 운영자가 콘솔 상세에서 항목과 사유를 보고 고쳐서 다시 낸다. 승인 시 격자 겹침(13452)을 만난 경우의 다음 조작도 이 반려이고, 항목 코드는 AREA 다.\n\n없는 신청은 404(13430), 심사 중이 아니면 409(13450), 항목 코드가 비었거나 허용 밖이거나 중복이면 400(13454) 이다.","operationId":"reject_3","parameters":[{"name":"submissionId","in":"path","description":"반려할 신청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventSubmissionRejectRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/admin/event-submissions/{submissionId}/approve":{"post":{"tags":["관리자 행사 등재 심사 (Admin Event Submission)"],"summary":"신청 승인","description":"신청을 승인하고 승인 번호(APR-2026-XXXX 꼴)를 부여한다. 요청 본문이 없다 — 승인 입력은 전부 저장된 신청에서 나온다.\n\n지역축제는 지도 홈 축제 칩 미션으로, 팝업스토어는 팝업 칩 미션으로 등재되어 재기동이나 재시드 없이 기존 미션 조회 API 에 즉시 나타난다. 판정 격자는 신청한 전 위치의 셀 합집합이고 대표 격자는 서버가 그 합집합에서 다시 계산한다.\n\n전이·이력·미션 등재가 한 트랜잭션이라 절반만 반영되는 결과가 없다. 응답을 받지 못했으면 상세를 재조회해 APPROVED 인지 확인한다 — 같은 신청을 다시 승인하면 409(13450) 다.\n\n없는 신청은 404(13430), 심사 중이 아니거나 동시 승인의 늦은 쪽은 409(13450), 종료일이 이미 지난 신청은 409(13451) 다.","operationId":"approve_2","parameters":[{"name":"submissionId","in":"path","description":"승인할 신청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionApproveResponseDto"}}}}}}},"/api/admin/email-change-requests/{requestId}/reject":{"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"아이디 변경 요청 반려","description":"요청을 반려하고 사유를 저장한다. 아이디는 바뀌지 않고 메일도 발송되지 않는다 — 반려 통보는 수기이고 저장된 사유가 그 재료다. 처리 후에는 같은 계정이 다시 접수할 수 있다.\n\n검토 기준 시각을 승인과 똑같이 요구하는 것은, 검토한 내용과 다른 요청을 그 사유로 반려하는 어긋남을 막기 위해서다.\n\n없는 요청은 404(1427), 이미 처리된 요청은 409(1428), 검토 이후 내용이 바뀌었으면 409(1429) 다.","operationId":"rejectEmailChange","parameters":[{"name":"requestId","in":"path","description":"반려할 요청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailChangeRejectRequestDto"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/admin/email-change-requests/{requestId}/approve":{"post":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"아이디 변경 요청 승인","description":"요청한 이메일로 로그인 아이디를 교체하고 새 이메일로 변경 완료를 통지한다. 요청 전이와 이메일 교체는 한 트랜잭션이라 함께 성공하거나 함께 실패한다. 비밀번호와 세션은 그대로이며 다음 로그인부터 새 아이디를 쓴다.\n\n발급·반려 통보와 달리 메일을 보내는 이유는 로그인 수단 자체가 바뀌는 사건이라서다 — 알리지 않으면 행사 운영자가 계정 접근을 잃는다. 발송이 실패해도 교체는 유지되며 emailSent 가 false 로 온다.\n\n없는 요청은 404(1427), 이미 처리된 요청은 409(1428), 검토 이후 재요청으로 내용이 바뀌었으면 409(1429), 요청한 이메일이 이미 다른 계정에 있으면 409(1409) 다.","operationId":"approveEmailChange","parameters":[{"name":"requestId","in":"path","description":"승인할 요청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailChangeApproveRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEmailChangeApproveResponseDto"}}}}}}},"/api/videos/{videoId}/visibility":{"patch":{"tags":["영상 (Video)"],"summary":"영상 공개 범위 전환","description":"본인 영상의 공개 범위를 PUBLIC·PRIVATE·FRIENDS 간 전환한다. 전환된 상태를 반환하며, 같은 값 재전환은 멱등하게 성공한다.","operationId":"setVisibility","parameters":[{"name":"videoId","in":"path","description":"공개 범위를 전환할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoVisibilityRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoVideoVisibilityResponseDto"}}}}}}},"/api/org/profile":{"get":{"tags":["행사 운영자 계정 (Org Account)"],"summary":"계정 설정 조회","description":"계정 설정 화면의 초기값이다. 아이디(이메일)는 읽기 전용으로 함께 내려간다.","operationId":"getProfile","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgProfileResponseDto"}}}}}},"patch":{"tags":["행사 운영자 계정 (Org Account)"],"summary":"담당자 정보 수정","description":"담당자 이름과 연락처를 바꾸고 변경 후 값을 반환한다. 아이디(이메일)는 이 API 로 바꿀 수 없다.","operationId":"updateProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrgProfileUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgProfileResponseDto"}}}}}}},"/api/org/event-submissions/{submissionId}":{"get":{"tags":["행사 등재 신청 (Org Submission)"],"summary":"신청 상세","description":"기본 정보와 위치 목록(순번·대표 격자·표시명 재료·제출 원본 사각형), 상태 이력, 반려 항목과 사유를 돌려준다. 반려 항목은 현재 상태가 반려일 때만 값이 있고, 과거 반려는 재제출 뒤에도 이력에 남는다.\n\n없는 신청과 남의 신청은 완전히 같은 실패 응답이다 — 응답 차이로 남의 신청 존재를 추측할 수 없다.","operationId":"getSubmission","parameters":[{"name":"submissionId","in":"path","description":"신청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionDetailResponseDto"}}}}}},"patch":{"tags":["행사 등재 신청 (Org Submission)"],"summary":"반려본 수정 재제출","description":"반려된 신청만 수정할 수 있고, 재제출하면 상태가 심사 중으로 돌아간다(신청 번호는 그대로다). 부분 수정이 아니라 전체 교체이고 등록 유형은 바꿀 수 없다 — 유형을 바꾸려면 새로 제출한다.\n\nimageS3Key 를 생략하거나 null 로 보내면 기존 대표 이미지가 유지되고, 새 pending 키를 보내면 교체된다.","operationId":"resubmit","parameters":[{"name":"submissionId","in":"path","description":"신청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventSubmissionUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionSubmitResponseDto"}}}}}}},"/api/notifications/{notificationId}/read":{"patch":{"tags":["알림 (Notification)"],"summary":"알림 하나 읽음 처리","description":"행을 탭했을 때 그 알림을 읽음으로 바꾼다 — 이미 읽은 알림을 다시 요청해도 성공이고 최초로 읽은 시각이 그대로 남는다. 없는 알림이나 남의 알림이면 10404 로, 둘을 구분하지 않는다.","operationId":"markRead","parameters":[{"name":"notificationId","in":"path","description":"읽음 처리할 알림 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":123}],"responses":{"200":{"description":"OK"}}}},"/api/notifications/read-all":{"patch":{"tags":["알림 (Notification)"],"summary":"알림 모두 읽음 처리","description":"안읽은 알림을 전부 읽음으로 바꾼다 — 안읽은 알림이 하나도 없어도 성공한다.","operationId":"markAllRead","responses":{"200":{"description":"OK"}}}},"/api/notifications/preferences/{category}":{"patch":{"tags":["알림 (Notification)"],"summary":"카테고리 수신 토글","description":"카테고리 하나의 수신 여부를 바꾸고 변경 후 전체 상태를 반환한다 — 같은 값 재전환은 멱등. category 가 8종(BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT, 대소문자 무시) 외면 10420 이다. off 는 발송만 막고 off 중 쌓인 알림이 on 복귀 후 재발송되는 일은 없다. MISSION_NEARBY 는 서버 발송이 없어 기기가 발화 전 이 설정을 조회해 로컬로 억제한다.","operationId":"update","parameters":[{"name":"category","in":"path","description":"알림 카테고리 — BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY (대소문자 무시)","required":true,"schema":{"type":"string"},"example":"HOTZONE"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPreferenceUpdateRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPreferenceResponseDto"}}}}}}},"/api/event-videos/{videoId}/comments/{commentId}":{"delete":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 삭제","description":"댓글을 실제로 지운다(복구 없음). 본인 댓글만 지울 수 있고 남의 댓글이면 403 + developCode 13403 이다.\n\n이미 지운 댓글을 다시 지우면 404 + 13407 이다 — 없는 댓글의 삭제를 성공으로 돌려주면 화면 상태 불일치가 감춰지기 때문이다(도움돼요 취소는 토글이라 멱등인 것과 다르다).\n\n아카이브된 행사(종료 30일 후)에서는 409 + 13422 다.","operationId":"deleteComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"commentId","in":"path","description":"댓글 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3021}],"responses":{"200":{"description":"OK"}}},"patch":{"tags":["행사 (Events)"],"summary":"행사 영상 댓글 수정","description":"댓글 내용을 통째로 바꾼다. 본인 댓글만 고칠 수 있고 남의 댓글이면 403 + developCode 13403, 없거나 다른 영상의 댓글이면 404 + 13407 이다.\n\n작성 시각은 그대로다(수정 이력을 남기지 않는다). 아카이브된 행사에서는 자기 댓글이든 남의 댓글이든 409 + 13422 로 같다 — 잠금이 권한 판정보다 앞이다.","operationId":"updateComment","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042},{"name":"commentId","in":"path","description":"댓글 id","required":true,"schema":{"type":"integer","format":"int64"},"example":3021}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventVideoCommentRequestDto"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoCommentResponseDto"}}}}}}},"/test/protected":{"get":{"tags":["test-protected-controller"],"operationId":"whoami","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{}}}}}}}},"/api/zones":{"get":{"tags":["구역 (Zone)"],"summary":"구역 목록 조회","description":"전체 구역(zone) 목록을 반환한다. 검색바에서 구역을 골라 지도를 옮기거나 구역 범위를 오버레이로 그릴 때 쓴다 — 표시명은 격자 응답의 zoneName·zoneCell 을 그대로 조립하면 되므로 이 목록으로 이름을 계산할 필요가 없다. 시딩 전이면 빈 배열(전 시스템이 행정동 폴백으로 동작).","operationId":"getZones","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListZoneResponseDto"}}}}}}},"/api/users/me":{"get":{"tags":["사용자 (User)"],"summary":"내 프로필 조회","description":"소셜 로그인이 자동 저장한 이메일·닉네임을 반환한다. 항상 본인 계정만 — 경로에 대상 식별자가 없다.","operationId":"getMe","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoUserProfileResponseDto"}}}}}},"delete":{"tags":["사용자 (User)"],"summary":"계정 삭제","description":"내 계정을 즉시·비가역 삭제한다. 연쇄 개인 데이터·영상 S3 객체가 제거되고 전 디바이스 세션이 무효화된다. 같은 이메일·카카오 계정으로 다시 로그인하면 신규 가입이다.","operationId":"deleteMe","parameters":[{"name":"Authorization","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/users/me/blocks":{"get":{"tags":["사용자 차단 (User Block)"],"summary":"내가 차단한 사용자 목록","description":"내가 차단한 사용자 전부를 차단 시각 내림차순으로 페이지 없이 반환한다. 닉네임·프로필 이미지는 조회 시점 값이다. 나를 차단한 사용자는 포함되지 않고, 차단이 없으면 빈 배열이다.","operationId":"getBlockedUsers","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListBlockedUserResponseDto"}}}}}}},"/api/search/trending":{"get":{"tags":["인기 검색어 (Trending)"],"summary":"인기 검색어 TOP 10","description":"오늘+어제(KST) 검색어 집계를 합산해 상위 10개를 순위·검색어로 반환한다. 동률은 검색어 사전순. 검색 횟수와 장소 정보는 포함하지 않으며(클릭 후 장소 검색 API 재호출), 집계가 없으면 200 + 빈 배열이다.","operationId":"getTrendingKeywords","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListTrendingKeywordResponseDto"}}}}}}},"/api/search/places":{"get":{"tags":["장소 검색 (Search)"],"summary":"장소 검색 (장소명 → 좌표·격자)","description":"카카오 로컬 키워드 검색 결과(정확도순 ≤15건)에 각 좌표의 격자 ID 를 얹어 반환한다. 선택 즉시 lat/lng 지도 이동 + gridId 격자 하이라이트. q 누락 400 / trim 후 빈 q·무매치 200 [] / 카카오 장애·타임아웃 502(developCode 5502). 비로그인도 호출할 수 있고 결과는 로그인 때와 같다 — 비로그인 호출은 X-Viewer-Session 헤더(공백 아님·최대 64자·콜론 불가)를 실으면 인기 검색어 집계에 잡히고, 안 실어도 검색은 정상 200 이다.\n\nlat·lng 에 지금 보고 있는 지도의 중심 좌표를 실으면 그 중심 반경 20km 안의 장소를 먼저 찾는다. 근처에 결과가 하나도 없으면 위치 없이 다시 찾아 전국 결과를 주므로 좌표를 붙였다는 이유로 결과가 사라지지는 않는다. 두 값은 반드시 한 쌍으로 보내야 하고, 한쪽만 오거나 숫자가 아니거나 대한민국 범위(위도 33~39·경도 124~132) 밖이면 400 + developCode 5400 이다. 좌표를 아예 안 보내면 종전과 똑같이 동작한다.","operationId":"searchPlaces","parameters":[{"name":"q","in":"query","description":"검색어 (자유 텍스트 장소명)","required":true,"schema":{"type":"string"},"example":"부산대"},{"name":"lat","in":"query","description":"지도 중심 위도 (33.0~39.0). lng 과 한 쌍으로만 유효하다","required":false,"schema":{"type":"number","format":"double"},"example":35.1578},{"name":"lng","in":"query","description":"지도 중심 경도 (124.0~132.0). lat 과 한 쌍으로만 유효하다","required":false,"schema":{"type":"number","format":"double"},"example":129.0594},{"name":"X-Viewer-Session","in":"header","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListPlaceSearchResponseDto"}}}}}}},"/api/regions/{regionCode}/grids":{"get":{"tags":["전역 탐색 (Region Explore)"],"summary":"행정동 격자 카드 리스트 + 헤더 카운트 조회","description":"그 행정동 격자들 중 전역 공개 콘텐츠(공개·인코딩 완료·타인 영상 포함)가 있는 격자를 카드로 반환한다. 카운트(gridCount·videoCount)는 limit 무관 전체 기준이라 지도 홈 패널(sort=LATEST&limit=20, SRS FR-MAP-10)과 전체 보기(limit 생략)가 같은 값을 받지만, **전역 공개 콘텐츠를 센 값이라 패널 헤더(\"이 지역 격자 N개 · 영상 M개\")에 쓰면 안 된다** — 헤더는 내 도감 집계 응답의 currentRegion(중심 동 전체의 내 것, MSG-374)이 채운다. 카드 커버는 격자 대표(cover)와 같은 영상이고 썸네일은 presigned GET URL 이다. 미존재·무콘텐츠 regionCode 는 404 가 아니라 200 + 카운트 0·빈 배열이다.","operationId":"getRegionGrids","parameters":[{"name":"regionCode","in":"path","description":"행정동 코드 — reverse-geocode·전체 지역 리스트의 regionCode 를 그대로 전달","required":true,"schema":{"type":"string"},"example":2644056000},{"name":"sort","in":"query","description":"정렬 — POPULAR(조회수 합)·LATEST(최신 공개 영상). 대문자 전용이며 소문자 포함 무효 값은 400 이다. 지도 홈 패널은 LATEST (SRS FR-MAP-10, 생략 기본값은 POPULAR 유지)","required":false,"schema":{"type":"string","default":"POPULAR","enum":["POPULAR","LATEST"]},"example":"LATEST"},{"name":"limit","in":"query","description":"카드 수 상한 — 지도 홈 패널은 20 (SRS FR-MAP-10). 생략하면 전부, 1 미만은 1 로 보정한다","required":false,"schema":{"type":"integer","format":"int32"},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionExploreResponseDto"}}}}}}},"/api/regions/stats":{"get":{"tags":["행정동 (Region)"],"summary":"내 행정동별 수집률 조회","description":"로그인 사용자가 점령(수집)한 격자를 행정동별로 집계한 수집률 리스트를 반환한다. parentCode 로 시군구를 좁힐 수 있고(실존하지 않는 코드면 404/6404), collectedOnly=false 면 롤백으로 0이 된 행정동도 포함한다. 수집이 없으면 404 가 아니라 200 + 빈 배열.","operationId":"getStats","parameters":[{"name":"parentCode","in":"query","description":"상위 시군구 코드. 생략하면 전국. 실존하지 않으면 6404","required":false,"schema":{"type":"string"},"example":11680},{"name":"collectedOnly","in":"query","description":"true=수집한 행정동만, false=손댄 행정동 전부(롤백 0-row 포함)","required":false,"schema":{"type":"boolean","default":true},"example":true}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionStatResponseDto"}}}}}}},"/api/regions/stats/national":{"get":{"tags":["행정동 (Region)"],"summary":"내 전국 탐험률 재료 (분자·분모)","description":"도감·프로필 헤더의 \"전체 지도 N% 탐험\" 재료. 내가 점령한 격자 수(전국 합)와 전국 격자 총수를 반올림 없는 원값 정수 2개로 반환한다. 비율·표시 자릿수·100 상한은 화면이 min(100, 분자/분모 × 100) 으로 계산한다. 수집이 없어도 오류가 아니라 분자 0. 분모가 0 이면 기준 데이터 미적재 상태라 화면은 비율을 그리지 않는다.","operationId":"getNationalStat","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionNationalStatResponseDto"}}}}}}},"/api/regions/stats/by-point":{"get":{"tags":["행정동 (Region)"],"summary":"현재 위치 행정동 탐험률 (좌표 → 수집률)","description":"도감 갤러리 진입 초기값. 현재 위치 좌표가 속한 행정동 1건의 내 수집률을 반환한다. 그 행정동에 수집이 없어도 0% 로 합성해 반환하고, 어떤 행정동에도 안 속하면(바다·국외) 404 가 아니라 200 + data null. 서비스 범위 밖 좌표는 400(6400).","operationId":"getStatByPoint","parameters":[{"name":"lat","in":"query","description":"위도","required":true,"schema":{"type":"number","format":"double"},"example":37.4979},{"name":"lng","in":"query","description":"경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0276}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionStatResponseDto"}}}}}}},"/api/regions/stats/by-grid":{"get":{"tags":["행정동 (Region)"],"summary":"격자 중심 행정동 탐험률 (격자 클릭 → 수집률)","description":"클릭한 격자의 중심점이 속한 행정동 1건의 내 수집률을 반환한다. 귀속 축이 수집률 집계(MSG-155)와 같아 탐험률·라벨이 일치한다. 중심점이 어떤 행정동에도 안 속하거나 gridId 형식이 이상하면 200 + data null(별도 에러 코드 없음).","operationId":"getStatByGrid","parameters":[{"name":"gridId","in":"query","description":"격자 ID \"{grid_y}_{grid_x}\"","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionStatResponseDto"}}}}}}},"/api/regions/reverse-geocode":{"get":{"tags":["행정동 (Region)"],"summary":"역지오코딩 (좌표 → 행정동)","description":"좌표를 포함하는 행정동 1건을 반환한다. 포함 행정동이 없으면(바다·국외) 404가 아니라 200 + data null. 서비스 좌표 범위(한국) 밖이면 400(6400).","operationId":"reverseGeocode","parameters":[{"name":"lat","in":"query","description":"위도","required":true,"schema":{"type":"number","format":"double"},"example":37.4979},{"name":"lng","in":"query","description":"경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0276}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionResponseDto"}}}}}}},"/api/regions/explore":{"get":{"tags":["전역 탐색 (Region Explore)"],"summary":"전체 지역 리스트 조회","description":"전역 공개 콘텐츠가 있는 행정동을 20개씩 반환한다. 로그인 사용자가 직접 최근 업로드한 지역이 먼저 나오고 나머지는 격자 수 내림차순이다. hasNext가 true면 nextCursor를 다음 요청의 cursor에 그대로 전달한다.","operationId":"getExploreRegions","parameters":[{"name":"cursor","in":"query","description":"직전 응답의 nextCursor. 첫 페이지는 생략","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoRegionExplorePageResponseDto"}}}}}}},"/api/regions/districts":{"get":{"tags":["행정동 (Region)"],"summary":"시군구 목록 (검색 지역 필터)","description":"검색 화면 \"전체 지역\" 목록용 시군구 전량. 이름·식별자와 그 구의 전체 격자 수를 준다. 격자 수는 사용자 무관 값이고 0 인 시군구는 빠진다. 정렬은 이름순, 같은 이름은 식별자순. 응답의 parentCode 는 /api/regions/stats 의 parentCode 로 그대로 이어 쓸 수 있다.","operationId":"getDistricts","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionDistrictResponseDto"}}}}}}},"/api/org/events":{"get":{"tags":["행사 운영자 콘솔 (Org)"],"summary":"승인 이벤트 목록 조회","description":"참여 신청 모달의 재료 — 시·도 칩, 시·도별 건수, 이벤트 목록이다. 담기는 것은 아직 끝나지 않은 회차(예정·진행 중)뿐이고, 종료된 행사(업로드 유예·아카이브)는 참여를 신청해도 열 자리가 없으므로 빠진다. 일반 사용자 조회와 달리 노출 시작 전인 예정 회차도 담긴다 — 심사에 시간이 걸려 행사 운영자는 미리 부모 이벤트를 골라야 한다.\n\ntotalCount 와 cityCounts 는 city·name 을 적용하지 않은 전체 기준이라 검색 중에도 칩 건수가 고정이고, events 에만 두 파라미터가 적용된다. cityCounts 는 건수 내림차순·동수는 이름 오름차순, events 는 시작일 오름차순·동시각은 회차 id 오름차순이다.\n\nplaceLabel 은 그 회차의 위치 중 표시 순서가 가장 앞선 것의 이름이고, 위치가 없으면 null 이다. 존재하지 않는 시·도 값은 실패가 아니라 빈 목록이다.","operationId":"getApprovedEvents","parameters":[{"name":"city","in":"query","description":"시·도 필터 — cityCounts 의 cityName 저장값과 정확 일치","required":false,"schema":{"type":"string"},"example":"부산"},{"name":"name","in":"query","description":"이벤트 이름 검색 — 부분 일치, 대소문자 무시","required":false,"schema":{"type":"string"},"example":"영화제"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOrgEventListResponseDto"}}}}}}},"/api/org/event-submissions/my":{"get":{"tags":["행사 등재 신청 (Org Submission)"],"summary":"내 신청 목록","description":"콘솔 홈 현황 카드와 최근 신청 목록의 재료다. 상태별 건수는 내 신청 전체 기준이고 목록은 최신 제출 순이다. 페이지네이션은 없다.","operationId":"getMySubmissions","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventSubmissionMyListResponseDto"}}}}}}},"/api/org/authorization-probe":{"get":{"tags":["org-probe-controller"],"operationId":"probe","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{}}}}}}}},"/api/notifications":{"get":{"tags":["알림 (Notification)"],"summary":"알림 목록 조회","description":"받은 알림을 최신순으로 한 페이지 반환한다. 최근 30일 이내 생성분만 보이고, 알림 설정을 꺼서 발송되지 않은 알림은 빠진다 — 전송률 상한이나 푸시 토큰 없음으로 발송되지 않은 알림은 보인다. 다음 페이지는 응답의 nextCursor 를 cursor 로 다시 넘긴다. 목록 조회는 읽음 상태를 바꾸지 않는다.","operationId":"getInbox","parameters":[{"name":"cursor","in":"query","description":"직전 응답의 nextCursor — 생략하면 첫 페이지","required":false,"schema":{"type":"integer","format":"int64"},"example":123},{"name":"size","in":"query","description":"페이지 크기 — 0 이하면 20, 50 초과면 50 으로 자른다","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPageResponseDto"}}}}}}},"/api/notifications/unread-count":{"get":{"tags":["알림 (Notification)"],"summary":"안읽은 알림 개수 조회","description":"목록과 같은 노출 조건으로 안읽은 알림 수를 센다 — 없으면 0 이다.","operationId":"getUnreadCount","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationUnreadCountResponseDto"}}}}}}},"/api/notifications/preferences":{"get":{"tags":["알림 (Notification)"],"summary":"알림 설정 조회","description":"카테고리 8종(BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT) 전부의 수신 상태를 반환한다. 설정을 만진 적 없는 사용자는 전부 true 다 — opt-out 기본 전부 on. MODERATION 은 설정 대상이 아니라 목록에 없다 (수신 거부 불가).","operationId":"getPreferences","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoNotificationPreferenceResponseDto"}}}}}}},"/api/missions/{missionId}":{"get":{"tags":["미션 (Missions)"],"summary":"미션 상세 조회","description":"미션 ID 하나의 상세 — 미션 정보와 렌더 shape(목록과 같은 필드), 내 진행도와 스탬프 보유 여부, 이 미션에 올라온 전체 영상 개수, 코스라면 포토스팟별 방문 여부·영상 개수를 한 번에 반환한다. spotStats 는 shape.spots 와 같은 순서로 오고, 코스가 아니면 null 대신 빈 배열이다.\n\n기간 판정은 하지 않는다 — 기간이 끝난 미션도 행이 남아 있으면 조회되고, 영상 개수는 그 미션이 활성일 때 촬영된 것만 센다(미션 영상 목록 GET /api/missions/{missionId}/videos 의 실제 후보 수와 항상 같다). 존재하지 않는 미션 ID 는 404 + developCode 12404(MISSION_NOT_FOUND)다.\n\n비로그인으로도 조회된다(MSG-454). 이때 사용자별 값은 빠진다 — progress 는 키는 있고 값이 null 이며 spotStats[].visited 는 전부 false 다. 미션 정보·전체 영상 수·스팟별 영상 수는 로그인과 같다.","operationId":"getMissionDetail","parameters":[{"name":"missionId","in":"path","description":"미션 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":412}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoMissionDetailResponseDto"}}}}}}},"/api/missions/progress":{"get":{"tags":["미션 (Missions)"],"summary":"미션별 내 진행도 조회","description":"미션 id 여러 개의 내 진행도(채운 칸/목표 칸)와 스탬프 보유 여부를 한 번에 반환한다. 채운 칸은 스탬프 판정과 같은 술어로 센다 — 미션 기간 안에 촬영한 내 영상(삭제 제외)이 있는 격자 수다. 영상을 전부 지우면 진행도는 0으로 돌아가지만 스탬프는 비회수라 completed 는 남는다 — \"0/1 인데 완료\"가 정상 응답이다.\n\nmissionIds 가 없거나 비면 빈 배열이고(오류 아님), 존재하지 않는 id 는 응답에서 빠진다. 기간이 끝난 미션도 조회된다. 배열 순서는 missionId 오름차순으로 고정된다(요청 순서 미보존). 300개 초과는 400 + developCode 12403 으로 거절한다.","operationId":"getMyProgress","parameters":[{"name":"missionIds","in":"query","description":"미션 id 목록 — 콤마 구분 또는 반복 파라미터. 없거나 비면 빈 배열 응답, 300개 초과는 거절","required":false,"schema":{"type":"array","items":{"type":"integer","format":"int64"}},"example":"412,413"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionProgressResponseDto"}}}}}}},"/api/missions/aggregation":{"get":{"tags":["미션 (Missions)"],"summary":"넓은 축척용 미션 행정 단위 집계 조회 (줌아웃)","description":"지도를 축소해 개별 핀을 그릴 수 없는 축척에서, bbox 안의 축제·팝업 미션을 행정 단위(동·구·시)로 묶어 지역 이름과 개수로 반환한다. 단위 전환 시점은 서버가 정하지 않으며 클라이언트가 화면 축척에 맞춰 unit 만 바꿔 부른다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 미션 수, 그 묶음의 미션 id 목록이 온다. 대표 좌표는 묶음에 속한 미션 귀속점의 평균이라 마커가 실제 데이터 위에 선다. missionIds 는 묶음 마커를 눌러 줌인한 뒤 개별 조회(GET /api/missions/active) 결과와 교집합을 내 목록을 좁히는 재료다 — 카드 재료는 개별 조회 응답에 있다.\n\n미션이 속한 격자 사각형이 아니라 그 사각형 중앙의 귀속점이 bbox 안인지로 센다. 사각형이 화면에 걸쳤지만 중심이 밖인 미션은 빠지며, 이 때문에 개별 조회와 집계를 갈아타는 순간 마커 수가 미세하게 달라질 수 있다. 행정동이 판정되지 않은 미션은 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 마지막에 온다. 범위 안에 미션이 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용, 정확히 상한값은 허용). 초과 시 400 + developCode 12401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히면 12400, type 이 없거나 EVENT·POPUP 이 아니면 12402, unit 이 없거나 미지원 값이면 12405 다. 응답에 사용자별 값은 없다.","operationId":"getMissionAggregates","parameters":[{"name":"type","in":"query","description":"미션 종류 — EVENT(지역축제), POPUP(팝업스토어). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"POPUP"},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"SIGUNGU"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionRegionAggregateResponseDto"}}}}}}},"/api/missions/active":{"get":{"tags":["미션 (Missions)"],"summary":"뷰포트 내 활성 미션 목록 조회","description":"지도 화면 bbox(남서~북동 좌표) 안의, 고른 종류(type)의 활성 미션을 유형별 렌더 shape(코스=PATH·축제/팝업=BOX)로 반환한다. bbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 잘라서 응답하지 않고 400 + developCode 12401(VIEWPORT_TOO_LARGE)로 거절한다. 클라이언트는 격자 개별 조회(GET /api/grids)를 멈추는 것과 같은 0.5도 지점에서 이 조회도 멈추고 확대 안내를 그린다.\n\n보이는 범위에 그 종류 미션이 없으면 실패가 아니라 빈 배열이다(뷰포트가 너무 넓은 12401 과 다른 상태). 한국 밖이지만 WGS84 정의역 안인 bbox 도 오류가 아니라 빈 배열이다. 응답에 사용자별 값은 없다 — 진행도는 GET /api/missions/progress 로 따로 받는다.","operationId":"getActiveMissionsInViewport","parameters":[{"name":"type","in":"query","description":"미션 종류 — EVENT(지역축제), POPUP(팝업스토어), COURSE(경로추천). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"POPUP"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMissionResponseDto"}}}}}}},"/api/hotzones":{"get":{"tags":["핫구역 (HotZone)"],"summary":"뷰포트 내 핫구역 조회","description":"지도 화면 bbox(남서~북동 좌표) 안의 핫구역을 핫스코어 내림차순으로 반환한다. 전국 상위 K(50)·최소 임계(3) 판정 후 뷰포트 필터 — 없으면 빈 목록이다.\n\n항목마다 표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). 이름 때문에 마커마다 단건 조회를 돌릴 필요가 없다.","operationId":"getHotZones","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoHotZoneListResponseDto"}}}}}}},"/api/hotzones/aggregation":{"get":{"tags":["핫구역 (HotZone)"],"summary":"뷰포트 내 핫구역 행정 단위 집계 조회","description":"축소 화면용 — 뷰포트 안 핫구역을 행정 단위(동·구·시)로 묶어 지역 이름과 핫 격자 수로 반환한다. 묶음 대상은 개별 조회(GET /api/hotzones)와 완전히 같은 판정 집합이라 두 화면을 갈아타도 세는 대상이 달라지지 않는다.\n\n항목마다 gridIds 가 함께 온다 — 묶음 마커를 눌러 줌인한 뒤 개별 조회 결과와 교집합으로 목록을 좁히는 재료다. count 는 핫 격자 수이고 핫스코어 합산이 아니다. 행정동이 판정되지 않은 격자는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 마지막에 온다. 범위 안에 핫 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용, 정확히 상한값은 허용). 초과 시 400 + developCode 8401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 누락·뒤집히면 8400, unit 이 없거나 미지원 값이면 8405 다. 응답에 사용자별 값은 없다.","operationId":"getHotZoneAggregates","parameters":[{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"SIGUNGU"},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListHotZoneRegionAggregateResponseDto"}}}}}}},"/api/grids":{"get":{"tags":["격자 (Grid)"],"summary":"뷰포트 내 색칠 격자 조회 (커서 페이지네이션)","description":"지도 화면 bbox(남서~북동 좌표) 안에서 내가 점령한 격자를 (grid_y, grid_x) 오름차순으로 반환한다. 응답의 nextCursor를 다음 요청 cursor에 넣어 이어서 조회한다. bbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 잘라서 응답하지 않고 400 + developCode 4402(VIEWPORT_TOO_LARGE)로 거절한다.\n\n항목마다 표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). 이름 때문에 다른 API를 더 호출할 필요가 없다.","operationId":"getOccupiedInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05},{"name":"cursor","in":"query","description":"다음 페이지 커서 (직전 응답의 nextCursor). 첫 페이지는 생략","required":false,"schema":{"type":"string"},"example":"MTk0MjJfOTU4Mg=="},{"name":"size","in":"query","description":"페이지 크기 (기본 1000, 최대 5000)","required":false,"schema":{"type":"integer","format":"int32","default":1000},"example":1000}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOccupiedGridPageResponseDto"}}}}}}},"/api/grids/{gridId}":{"get":{"tags":["격자 (Grid)"],"summary":"단일 격자 색칠 상태 조회","description":"특정 격자를 내가 점령(색칠)했는지와 내 영상 수를 반환한다. 미점령 격자도 404가 아니라 occupied=false로 응답한다.\n\n표시 이름 재료가 함께 온다: zoneName이 null이면 regionName(행정동)이 표시 이름이다(폴백에는 칸 번호를 붙이지 않는다). regionName은 아직 아무도 영상을 올리지 않은 격자에도 실리고, 어느 행정동에도 속하지 않거나 서비스 범위(한국) 밖인 격자면 null이다(에러가 아니다).","operationId":"getCell","parameters":[{"name":"gridId","in":"path","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridCellResponseDto"}}}}}}},"/api/grids/{gridId}/videos":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 영상 목록 조회","description":"그 격자에 쌓인 공개(PUBLIC)·READY 영상을 전역(본인·타인 포함)에서 조회수(viewCount) → 최신(createdAt) 순으로 페이지 조회한다. 비공개·삭제·인코딩 미완 영상은 본인 것이라도 제외한다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 무효 커서는 400(INVALID_CURSOR)이고, size 는 1~50 밖이면 클램프된다. 후보가 없거나 존재하지 않는 gridId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다. 로그인 요청이면 요청자와 차단 관계(어느 방향이든)인 작성자의 영상은 빠지고, 비로그인이면 차단과 무관하게 같은 결과다. 항목의 userId 는 작성자 식별자라 차단(POST /api/users/{userId}/block)의 경로 값으로 쓴다.","operationId":"getGridGlobalVideos","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"},{"name":"cursor","in":"query","description":"직전 응답의 nextCursor (opaque). 생략하면 첫 페이지","required":false,"schema":{"type":"string"}},{"name":"size","in":"query","description":"페이지 크기 (1~50, 기본 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridVideoPageResponseDto"}}}}}}},"/api/grids/{gridId}/my-videos":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자별 내 영상 리스트 조회","description":"로그인 사용자가 해당 격자에 올린 본인 영상을 최근 업로드 순(createdAt DESC)으로 반환한다. 미점령·타인만 점령한 격자·존재하지 않는 gridId 는 빈 배열이다. 썸네일은 presigned GET URL 로 내려주며 READY 이전이면 null 이다.","operationId":"getGridVideos","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridVideoResponseDto"}}}}}}},"/api/grids/{gridId}/missions":{"get":{"tags":["미션 (Missions)"],"summary":"격자가 대표 격자인 미션 조회","description":"지도에서 누른 격자가 어느 축제·팝업 미션의 자리인지 되짚는다. 미션 경유로 올린 영상은 그 미션의 대표 격자 한 칸에만 저장되므로, 영상이 모인 칸을 눌러 무슨 미션이었는지 확인하는 경로다.\n\n기간 필터가 없다 — 끝난 축제도 담긴다. 진행 중인지 시작 전인지 끝났는지는 startAt·endAt 을 서버 시각과 견주어 화면이 판정한다. 배열 첫 항목이 화면 진입 기본값이 되도록 진행 중 → 시작 전(임박한 순) → 종료(최근 종료 순)로 정렬한다.\n\n판정 범위(축제 9×9)에만 걸친 격자는 나오지 않는다 — 나오는 것은 영상이 모인 자리로 지목된 미션뿐이다. 어떤 미션의 대표 격자도 아닌 격자와 격자 형식이 아닌 문자열은 오류가 아니라 빈 배열이다. videoCount 는 미션 상세의 videoCount 와 같은 술어라 두 화면의 숫자가 어긋나지 않는다. 비로그인으로도 조회할 수 있다.","operationId":"getMissionsByGrid","parameters":[{"name":"gridId","in":"path","description":"격자 id — \"{gridY}_{gridX}\" 포맷","required":true,"schema":{"type":"string"},"example":"19443_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridMissionResponseDto"}}}}}}},"/api/grids/{gridId}/hourly-uploads":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 시간대 분포 조회","description":"그 격자의 공개(PUBLIC)·READY 영상이 업로드된 시간대 분포를 KST 0시부터 23시까지 24구간 개수로 반환한다. 세는 대상은 전역 영상 목록(/videos)과 같아 카드에 보이는 영상만 세어진다 — 비공개·삭제·인코딩 미완 영상은 본인 것이라도 빠진다. 집계 구간은 전체 누적이며, 응답의 hours 는 항상 24개·hour 오름차순이라 빈 시간대도 count 0 으로 실린다. 공개 영상이 없는 격자·존재하지 않는 gridId 도 전 구간 0 인 정상 응답이다.","operationId":"getGridHourlyUploads","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridHourlyUploadResponseDto"}}}}}}},"/api/grids/{gridId}/event-locations":{"get":{"tags":["행사 (Events)"],"summary":"격자가 속한 행사 위치 조회","description":"지도에서 누른 격자가 어느 행사 위치에 속하는지 해석한다. 영상은 위치의 대표 격자 하나에만 저장되므로, 영역 안 아무 격자나 눌러도 같은 위치가 나오는 이 역조회가 위치별 영상 피드로 들어가는 유일한 경로다.\n\n같은 장소에서 행사가 여러 번 열렸으면 회차마다 한 항목씩 배열로 온다. 배열 첫 항목이 화면 진입 기본값이 되도록 진행 중 → 예정 → 업로드 유예 → 아카이브 순으로 정렬하며, 예정끼리는 임박한 순, 나머지는 최근 순이다. 아직 노출 기간 전인 예정 회차는 배열에 담기지 않는다.\n\n어떤 행사 위치에도 속하지 않는 격자와 격자 형식이 아닌 문자열은 오류가 아니라 빈 배열이다. 비로그인으로도 조회할 수 있다.","operationId":"getEventLocationsByGrid","parameters":[{"name":"gridId","in":"path","description":"격자 id — \"{gridY}_{gridX}\" 포맷","required":true,"schema":{"type":"string"},"example":"19443_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListGridEventLocationResponseDto"}}}}}}},"/api/grids/{gridId}/cover":{"get":{"tags":["격자 상세 (Grid Videos)"],"summary":"격자 전역 대표 영상 조회","description":"그 격자를 전역에서 대표하는 영상 1건을 반환한다. 공개(PUBLIC)·READY 영상 중 조회수(view_count) → 최신(createdAt) 순으로 뽑으며, 본인·타인 영상 모두 후보다. 비공개·삭제·인코딩 미완 영상은 제외한다. 후보가 없으면(미점령·비공개만·존재하지 않는 gridId) data 는 null 이다. 썸네일은 presigned GET URL 로 내려준다.","operationId":"getGridCover","parameters":[{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridCoverVideoResponseDto"}}}}}}},"/api/grids/aggregation":{"get":{"tags":["격자 (Grid)"],"summary":"뷰포트 내 색칠 격자 행정 단위 집계 조회 (줌아웃)","description":"응답 data는 {currentRegion, items} 객체다. currentRegion은 뷰포트 중심이 속한 행정동의 이름과 그 동 전체에서 내가 점령한 격자 수·영상 수를 담는다. 화면 범위나 unit과 무관하며, 중심이 해상 또는 서비스 범위 밖일 때만 null이다.\n\nitems는 bbox 안에서 내가 점령한 격자를 행정 단위로 묶어 센 목록이다. 단위 전환 시점은 서버가 정하지 않으며 클라이언트가 화면 축척에 맞춰 unit만 바꿔 부른다. items가 비어 있어도 한국 내 중심점의 currentRegion은 이름과 0 집계를 독립적으로 담는다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 격자 수가 온다. 대표 좌표는 그 묶음에 속한 점령 격자 중심의 평균이라 마커가 실제 데이터 위에 선다. 어느 단위로 묶어도, 항목을 더 묶어 합산해도 같은 bbox 개별 격자 조회의 총 개수와 일치한다.\n\n행정동이 판정되지 않은 격자(해상 등)는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 온다. 점령 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용). 초과 시 400 + developCode 4402, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히면 4401, unit 이 없거나 미지원 값이면 4405 다.","operationId":"getOccupiedAggregatesInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"DONG"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoGridAggregationResponseDto"}}}}}}},"/api/friends":{"get":{"tags":["친구 (Friend)"],"summary":"친구 목록 조회","description":"수락된 친구 전체를 반환한다 — 누가 먼저 요청했는지와 무관하다. 기본 정렬은 친구가 된 시각 내림차순이고 sort=nickname 이면 닉네임순이다. 친구가 없으면 빈 배열.","operationId":"getFriends","parameters":[{"name":"sort","in":"query","description":"정렬 기준 — recent(기본, 친구가 된 시각 내림차순) 또는 nickname","required":false,"schema":{"type":"string"},"example":"nickname"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFriendListItemResponseDto"}}}}}}},"/api/friends/{userId}/profile":{"get":{"tags":["친구 (Friend)"],"summary":"친구 프로필·도감 요약 조회","description":"친구의 프로필(닉네임·프로필 이미지·도감 색상)과 도감 요약(수집 격자 수·영상 총합·방문 동 수), 최근 수집 격자 최대 30개를 한 번에 반환한다. 도감 요약 수치는 그 친구가 자기 도감에서 보는 값과 같다. 썸네일은 그 격자에 재생 가능한 공개 영상이 있을 때만 붙는다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendProfile","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendProfileResponseDto"}}}}}}},"/api/friends/{userId}/grids":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 뷰포트 조회","description":"지도 화면 bbox(남서~북동 좌표) 안에서 그 친구가 점령한 격자를 (grid_y, grid_x) 오름차순으로 반환한다. 응답 형상·검증 규칙·에러는 내 격자 조회(GET /api/grids)와 같다 — 응답의 nextCursor 를 다음 요청 cursor 에 넣어 이어 조회하고, bbox span 상한 0.5도는 위도·경도 각 변에 따로 적용되며 초과 시 400 + 4402(VIEWPORT_TOO_LARGE)로 거절된다. 격자 색상은 내려주지 않는다(FE 단일색 렌더). 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGrids","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.5},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.0},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":37.55},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":127.05},{"name":"cursor","in":"query","description":"다음 페이지 커서 (직전 응답의 nextCursor). 첫 페이지는 생략","required":false,"schema":{"type":"string"},"example":"MTk0MjJfOTU4Mg=="},{"name":"size","in":"query","description":"페이지 크기 (기본 1000, 최대 5000)","required":false,"schema":{"type":"integer","format":"int32","default":1000},"example":1000}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoOccupiedGridPageResponseDto"}}}}}}},"/api/friends/{userId}/grids/{gridId}/videos":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 영상 목록 조회","description":"그 친구가 해당 격자에 올린 영상을 최근 업로드 순으로 반환한다. 친구에게 공개된 영상(전체 공개·친구만 보기)만 담기고 비공개 영상은 포함되지 않으며, 삭제·인코딩 미완 영상도 제외된다 — 목록의 영상은 모두 재생 조회로 바로 진입할 수 있다. 친구가 점령하지 않은 격자·존재하지 않는 gridId 는 빈 배열이다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGridVideos","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"gridId","in":"path","description":"격자 ID","required":true,"schema":{"type":"string"},"example":"19422_9582"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListFriendGridVideoResponseDto"}}}}}}},"/api/friends/{userId}/grids/aggregation":{"get":{"tags":["친구 (Friend)"],"summary":"친구 격자 행정 단위 집계 조회 (줌아웃)","description":"지도를 축소한 시야에서 그 친구가 점령한 격자를 행정 단위로 묶어 센 목록을 페이지 없이 한 번에 반환한다. 파라미터·에러는 내 집계 조회(GET /api/grids/aggregation)와 같고, 묶음 항목의 공통 필드는 내 집계 조회와 같다. 다만 친구 응답은 currentRegion/items 겉면 없이 기존 배열로 반환한다. 단위 전환 시점은 서버가 정하지 않고 클라이언트가 화면 축척에 맞춰 unit 만 바꿔 부른다.\n\n항목마다 마커 식별 키(regionCode), 표시 이름, 대표 좌표, 격자 수가 온다. 행정동이 판정되지 않은 격자(해상 등)는 제외가 아니라 regionCode·name 이 null 인 항목 하나로 묶여 오고, 그 친구가 점령한 격자가 없으면 빈 배열이다.\n\nbbox span 상한은 단위별로 다르다(DONG 1도, SIGUNGU 4도, SIDO 10도 — 위도·경도 각 변에 따로 적용). 초과 시 400 + developCode 4402, bbox 가 뒤집히거나 파라미터가 빠지면 4401, unit 이 없거나 미지원 값이면 4405 다. 친구가 아닌 사용자·본인·존재하지 않는 사용자 조회는 모두 같은 404 다.","operationId":"getFriendGridAggregates","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.3},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.2},{"name":"unit","in":"query","description":"집계 단위 — DONG(동), SIGUNGU(시군구), SIDO(시도). 대소문자 무관","required":true,"schema":{"type":"string"},"example":"DONG"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionAggregateResponseDto"}}}}}}},"/api/friends/requests/received":{"get":{"tags":["친구 (Friend)"],"summary":"받은 친구 요청 목록","description":"내가 수신자인 대기 중 요청을 최신순으로 반환한다. 항목의 requesterId 를 수락/거절 경로 변수로 그대로 쓴다.","operationId":"getReceivedRequests","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListReceivedFriendRequestResponseDto"}}}}}}},"/api/friends/preview":{"get":{"tags":["친구 (Friend)"],"summary":"친구 코드 미리보기","description":"요청을 보내기 전 확인 화면용 — 코드 소유자의 닉네임과 나와의 관계 상태(relation)를 반환한다. relation 은 SELF(내 코드)·NONE(관계 없음)·OUTGOING_PENDING(내가 보낸 요청 대기)·INCOMING_PENDING(상대가 보낸 요청 대기)·FRIENDS(이미 친구) 다섯 값이고 조회 시점 실시간 판정이다. 미리보기는 힌트일 뿐이며 최종 검증(자기 자신·중복 등)은 요청 API 가 다시 수행한다.","operationId":"preview","parameters":[{"name":"code","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendPreviewResponseDto"}}}}}}},"/api/friends/code":{"get":{"tags":["친구 (Friend)"],"summary":"내 친구 코드 조회","description":"가입 시 자동 부여된 고정 8자 코드를 반환한다. 상대에게 임의 채널(카톡 등)로 공유하면 상대가 이 코드로 친구 요청을 보낼 수 있다. 재발급 없음.","operationId":"getMyFriendCode","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoFriendCodeResponseDto"}}}}}}},"/api/event-videos/{videoId}":{"get":{"tags":["행사 (Events)"],"summary":"행사 영상 상세 조회","description":"영상 하나의 재생본 presigned GET URL 과 표시 재료를 돌려준다. 소속 행사 회차·위치·대표 격자와 그 표시명 재료가 함께 담겨, 상세 화면이 추가 호출 없이 위치줄을 그린다.\n\n피드에 보이는 영상만 열린다 — 삭제·블라인드·비공개·처리 미완료 영상은 올린 본인에게도 404 + developCode 13406 이다(본인 영상 확인은 GET /api/videos/{videoId}). 행사 영상이 아닌 영상 id 도 같은 404 이고, 작성자와 차단 관계(어느 방향이든)인 요청자에게도 같은 404 다.\n\ninteractionLocked 는 아카이브 전환(행사 종료 + 30일)부터 true 이며 댓글·도움돼요 입력 UI 를 비활성화하는 재료다(기존 수는 계속 표시. 유예 기간에는 반응을 계속 남길 수 있다). 재생 URL 을 발급받은 타인 조회는 조회수를 올린다 — 비로그인 조회도 포함이고 올린 본인은 제외다.","operationId":"getVideoDetail","parameters":[{"name":"videoId","in":"path","description":"영상 id","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventVideoDetailResponseDto"}}}}}}},"/api/event-occurrences":{"get":{"tags":["행사 (Events)"],"summary":"뷰포트 내 행사 회차 목록 조회","description":"지도 화면 bbox(남서~북동 좌표) 안에 노출 영역이 걸친 행사 회차를 반환한다. 담기는 것은 진행 중이거나, 시작 2주 전부터의 노출 기간에 든 예정 회차이거나, 종료 뒤 30일의 업로드 유예 기간에 든 회차다 — 아카이브(종료 30일 경과) 회차만 칩에서 빠지고 상세·격자 역조회로 접근한다. 아직 노출 기간 전인 예정 회차는 존재 자체를 숨긴다.\n\n정렬은 시 이름 → 시작일 → 회차 id 오름차순이라, 시 칩 아래에 그 시의 행사 칩을 나열하는 화면이 매 요청 같은 순서를 받는다. 보이는 범위에 행사가 없으면 실패가 아니라 빈 배열이다.\n\nbbox span 상한은 0.5도로 위도·경도 각 변에 따로 적용된다(정확히 0.5도는 허용). 초과 시 400 + developCode 13401, 좌표가 WGS84 범위를 벗어나거나 bbox 가 뒤집히거나 파라미터가 빠지면 13400 이다. D-day 는 startsAt 을 KST 로 읽어 클라이언트가 계산한다.","operationId":"getOccurrencesInViewport","parameters":[{"name":"swLat","in":"query","description":"남서 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.1},{"name":"swLng","in":"query","description":"남서 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":128.9},{"name":"neLat","in":"query","description":"북동 모서리 위도","required":true,"schema":{"type":"number","format":"double"},"example":35.2},{"name":"neLng","in":"query","description":"북동 모서리 경도","required":true,"schema":{"type":"number","format":"double"},"example":129.1}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListEventOccurrenceChipResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}":{"get":{"tags":["행사 (Events)"],"summary":"행사 회차 상세 조회","description":"이벤트 헤더 재료 — 행사명, 기간, 업로드 마감(종료 30일 후), 서버 시각 기준 상태, 알림 구독 여부, 같은 시리즈의 지난 회차 목록이다. 상태는 저장값이 아니라 요청 시점 계산이며 경계 정각은 다음 상태에 속한다(종료 정각부터 UPLOAD_GRACE).\n\n지난 회차는 최신순이고 예정 회차는 담기지 않는다. 그 회차의 위치·영상은 회차 id 로 위치 목록을 다시 부르면 되므로 회차 간 데이터가 섞이지 않는다. 알림 구독 여부는 구독을 켰으면서 회차가 예정이거나 진행 중일 때만 true 다 — 비로그인 열람과 종료된 회차는 false 다.\n\n존재하지 않는 회차와 아직 노출 기간 전인 예정 회차는 똑같이 404 + developCode 13404 다 — 노출 전 행사의 존재를 id 대입으로 알아낼 수 없다.","operationId":"getOccurrenceDetail","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventOccurrenceDetailResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/viewer-count":{"get":{"tags":["이벤트 (Event)"],"summary":"현재 열람 인원 조회","description":"viewerCount 0 은 아무도 없음(표시), null 은 캐시 장애(숨김)다. 응답이 사용자 무관이라 인증 없이 호출할 수 있다.","operationId":"getViewerCount","parameters":[{"name":"occurrenceId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoEventViewerCountResponseDto"}}}}}}},"/api/event-occurrences/{occurrenceId}/locations":{"get":{"tags":["행사 (Events)"],"summary":"행사 회차의 위치 목록 조회","description":"회차에 속한 행사 위치(팝업·체험존·퍼레이드 등)와 각 위치의 격자 영역, 대표 격자, 표시명 재료, 영상 수를 반환한다. 영상 수는 집계 테이블 없이 조회 시점에 세며, 위치별 영상 피드에 실제로 보이는 영상만 센다(삭제·비공개·처리 미완료 제외).\n\ngridIds 는 화면에서 영역을 채색하는 재료이고 영상은 그중 representativeGridId 하나에만 붙는다. 표시명은 대표 격자 기준으로 `zoneName + \" \" + zoneCell`, 구역 밖이면 regionName 을 쓴다. 정렬은 표시 순서 → 위치 id 오름차순이다. 위치가 없으면 빈 배열이고, 존재하지 않는 회차와 노출 기간 전인 예정 회차는 404 + developCode 13404 다.","operationId":"getLocations","parameters":[{"name":"occurrenceId","in":"path","description":"행사 회차 id","required":true,"schema":{"type":"integer","format":"int64"},"example":12}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListEventLocationResponseDto"}}}}}}},"/api/collections/videos":{"get":{"tags":["도감 (Collection)"],"summary":"동 단위 내 영상 조회","description":"행정동(regionCode) 격자들에 올린 로그인 사용자의 영상을 created_at 내림차순으로 반환한다(무커서). regionCode 는 by-grid 응답의 regionCode 를 그대로 넘긴다. 귀속은 격자 축이라 영상 좌표가 옆 동이어도 격자 소속 행정동 기준으로 포함된다. 내 도감이라 PRIVATE·인코딩 중 영상도 포함하며(status ACTIVE 만), 그 행정동에 내 영상이 없거나 미존재 regionCode 면 에러 없이 빈 배열을 받는다.","operationId":"getRegionVideos","parameters":[{"name":"regionCode","in":"query","description":"행정동 코드 — by-grid 응답의 regionCode 를 그대로 전달","required":true,"schema":{"type":"string"},"example":1168051500}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListRegionVideoResponseDto"}}}}}}},"/api/collections/upload-history":{"get":{"tags":["도감 (Collection)"],"summary":"날짜별 업로드 기록 조회","description":"로그인 사용자 본인의 업로드를 KST 날짜로 접어, 업로드가 있었던 날과 그날의 건수를 날짜 오름차순으로 반환한다(잔디 재료 — 빈 날은 항목 없음, 빈 칸 채우기는 FE 몫). 삭제·블라인드된 영상의 업로드도 센다. 업로드 0건 사용자는 에러 없이 빈 배열을 받는다.","operationId":"getUploadHistory","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListUploadHistoryResponseDto"}}}}}}},"/api/collections/summary":{"get":{"tags":["도감 (Collection)"],"summary":"개인 도감 요약 조회","description":"로그인 사용자의 점령한 격자 수·올린 영상 총합·방문한 행정동 수에 더해 현재 스트릭·최장 스트릭·획득 뱃지 수를 한 번에 반환한다. 현재 스트릭은 마지막 기록이 KST 그제 이전이면 0이다. 업로드 경험 0 사용자도 에러 없이 여섯 값이 모두 0으로 응답한다.","operationId":"getSummary","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoCollectionSummaryResponseDto"}}}}}}},"/api/collections/grids":{"get":{"tags":["도감 (Collection)"],"summary":"갤러리 격자 목록 조회","description":"로그인 사용자가 수집한 격자를 카드로 반환한다(무커서). 파라미터를 모두 생략하면 전국을 first_collected_at 내림차순 최대 30개로 준다(기존 계약). regionCode 를 주면 그 행정동에 속한 내 격자만 나가며, 귀속은 격자 축이라 영상 좌표가 옆 동이어도 격자 소속 행정동 기준으로 잡힌다. 각 항목은 gridId·gridY/gridX·수집/방문 시각·영상 수·cover 영상 ID·cover 썸네일 URL·cover 길이(초)를 담는다. 내 격자가 없거나 미존재 regionCode 면 에러 없이 빈 배열을 받는다.","operationId":"getCollectionGrids","parameters":[{"name":"regionCode","in":"query","description":"행정동 코드 — 생략하면 전국. by-grid 응답의 regionCode 를 그대로 전달","required":false,"schema":{"type":"string"},"example":1168051500},{"name":"sort","in":"query","description":"정렬 축 — COLLECTED(수집 시각순, 기본) 또는 UPLOADED(최신 업로드순)","required":false,"schema":{"type":"string","default":"COLLECTED","enum":["COLLECTED","UPLOADED"]}},{"name":"limit","in":"query","description":"카드 수 상한 — 지도 홈 패널은 20 (SRS FR-MAP-10). 생략하면 regionCode 없을 때 30, regionCode 있을 때 그 동네 전부. 1 미만은 1 로 보정한다","required":false,"schema":{"type":"integer","format":"int32"},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListCollectionGridResponseDto"}}}}}}},"/api/badges":{"get":{"tags":["뱃지 (Badge)"],"summary":"내 뱃지 전체 목록","description":"시딩된 뱃지를 내 획득 상태와 함께 시딩 순(badges.id 오름차순)으로 반환한다. 은퇴 뱃지(retired_at 있음)는 획득자에게만 보이고 미획득자 목록에서는 빠진다 — 그래서 사용자마다 행 수가 다를 수 있다. 미획득 행은 earned false·earnedAt null·isNew false·featuredRank null. 이번 응답에 노출된 미확인(새 뱃지) 행은 자동으로 확인 처리되어 다음 조회부터 isNew false 가 된다.","operationId":"findMyBadges","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoListMyBadgeResponseDto"}}}}}}},"/api/authorization-probe":{"get":{"tags":["catch-all-probe-controller"],"operationId":"probe_1","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{}}}}}}}},"/api/auth/password/status":{"get":{"tags":["비밀번호 (Password)"],"summary":"비밀번호 강제 변경 상태 조회","description":"true 면 초기 비밀번호 상태라 행사 등재 콘솔(/api/org/**)이 전부 막힌다. 비밀번호가 없는 소셜 계정은 항상 false 다.","operationId":"getStatus","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoPasswordStatusResponseDto"}}}}}}},"/api/auth/oauth/kakao/authorize":{"get":{"tags":["인증 (Auth)"],"summary":"카카오 로그인 시작 (인가 진입점)","description":"웹 로그인의 시작점이다. 클라이언트는 이 URL 로 이동하기만 하면 된다(location.href). 서버가 카카오 인가 URL(client_id·response_type=code·scope=openid·nonce 포함)을 조립해 302 로 보내면서 같은 응답에 OAUTH_NONCE 쿠키(HttpOnly, 10분)를 심는다. 그래서 scope=openid 누락이나 nonce 누락이 구조적으로 불가능하고, REST API 키가 클라이언트 코드로 나갈 일도 없다. 응답은 리다이렉트라 공통 응답 포맷을 쓰지 않는다.","operationId":"redirectToKakaoAuthorize","parameters":[{"name":"redirectUri","in":"query","description":"카카오 콜백 URI. 콘솔 등록값과 정확히 일치해야 한다(검증 주체는 카카오).","required":true,"schema":{"type":"string"},"example":"http://localhost:5173/oauth/kakao/callback"},{"name":"state","in":"query","description":"콜백 위조 검증용 난수. 서버는 손대지 않고 인가 URL 에 그대로 전달한다.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/admin/videos/{videoId}":{"get":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"관리자 단건 영상 확인","description":"신고 판단용으로 영상 하나를 확인한다 — 공개범위와 상태(BLINDED 포함)를 무시하고 요청 시점에 재생·썸네일 presigned URL 을 발급하며, 조회수를 올리지 않는다. 처리 상태가 READY 가 아니면 playbackUrl 과 expiresInSec 은 null 이다. 없는 영상과 삭제된 영상은 404(3404) 다.","operationId":"getVideoForReview","parameters":[{"name":"videoId","in":"path","description":"확인할 영상 ID","required":true,"schema":{"type":"integer","format":"int64"},"example":1042}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminVideoReviewResponseDto"}}}}}}},"/api/admin/reports":{"get":{"tags":["관리자 신고 처리 (Admin Report)"],"summary":"신고 목록 조회","description":"상태 필터 기준으로 신고를 접수 최신순 페이지 단위로 조회한다. 기본은 미처리(PENDING) 신고다. 항목에 신고자·영상 소유자 닉네임과 영상 현재 상태가 함께 담겨 목록만으로 판단할 수 있다. 지원하지 않는 status 는 400(11420), page 음수나 size 범위(1~100) 밖은 400(11421) 이다. REVIEWING 은 유효한 값이지만 만드는 경로가 없어 항상 빈 목록이다.","operationId":"getReports","parameters":[{"name":"status","in":"query","description":"신고 상태 필터 (PENDING, REVIEWING, RESOLVED, REJECTED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"PENDING"},"example":"PENDING"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminReportListResponseDto"}}}}}}},"/api/admin/org-account-requests":{"get":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"계정 발급 요청 목록 조회","description":"상태 필터 기준으로 발급 요청을 마지막 접수 최신순 페이지 단위로 조회한다. 기본은 대기(PENDING) 요청이다. 상태별 건수 3종이 필터와 무관하게 함께 실려 탭 뱃지를 그릴 수 있다.\n\n지원하지 않는 status 는 400(1424), page 음수나 size 범위(1~100) 밖은 400(1425) 이다.","operationId":"getRequests","parameters":[{"name":"status","in":"query","description":"처리 상태 필터 (PENDING, ISSUED, REJECTED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"PENDING"},"example":"PENDING"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminOrgAccountRequestListResponseDto"}}}}}}},"/api/admin/org-account-requests/{requestId}":{"get":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"계정 발급 요청 상세 조회","description":"접수 필드 전체와 처리 결과를 조회한다. 응답의 updatedAt 은 승인·반려 요청에 그대로 되돌려 보내야 하는 검토 기준 시각이다 — 검토와 처리 사이에 신청 내용이 바뀌면 그 값으로 걸러진다.\n\n없는 요청은 404(1421) 다.","operationId":"getRequest","parameters":[{"name":"requestId","in":"path","description":"조회할 요청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminOrgAccountRequestDetailResponseDto"}}}}}}},"/api/admin/events":{"get":{"tags":["관리자 승인 행사 (Admin Approved Event)"],"summary":"승인 행사 목록 조회","description":"승인된 행사를 노출 중(EXPOSED)·예정(UPCOMING)·종료(ENDED) 탭으로 조회한다. 기본은 노출 중이다. 상태는 저장값이 아니라 조회 시점 KST 오늘과 행사 기간으로 파생하므로 시작일 당일은 노출 중, 종료일 당일도 노출 중이고 그 다음 날부터 종료다.\n\n탭 건수 3종은 탭과 무관한 전체 집계라 화면 뱃지에 그대로 쓴다. 노출이 중지된 행사도 탭에 그대로 남고 unpublished·unpublishedAt·unpublishReason 으로 구분된다 — 무엇을 왜 내렸는지 관리자가 계속 확인할 수 있어야 하기 때문이다.\n\n지원하지 않는 status 는 400(13455), page 음수나 size 범위(1~100) 밖은 400(13456) 이다.","operationId":"getEvents","parameters":[{"name":"status","in":"query","description":"탭 필터 (EXPOSED, UPCOMING, ENDED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"EXPOSED"},"example":"EXPOSED"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminApprovedEventListResponseDto"}}}}}}},"/api/admin/event-submissions":{"get":{"tags":["관리자 행사 등재 심사 (Admin Event Submission)"],"summary":"심사 큐 조회","description":"상태 필터 기준으로 신청을 접수 최신순 페이지 단위로 조회한다. 기본은 심사 중(IN_REVIEW) 신청이다. 상태별 건수 3종이 필터와 무관하게 함께 실려 탭 뱃지를 그릴 수 있다.\n\n항목의 organizerName 은 신청 폼의 주최 기관이고 orgName 은 신청 계정에 등록된 기관명이라, 둘이 다르면 그 자체가 심사 신호다.\n\n지원하지 않는 status 는 400(13455), page 음수나 size 범위(1~100) 밖은 400(13456) 이다.","operationId":"getSubmissions","parameters":[{"name":"status","in":"query","description":"신청 상태 필터 (IN_REVIEW, APPROVED, REJECTED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"IN_REVIEW"},"example":"IN_REVIEW"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminEventSubmissionListResponseDto"}}}}}}},"/api/admin/event-submissions/{submissionId}":{"get":{"tags":["관리자 행사 등재 심사 (Admin Event Submission)"],"summary":"심사 상세 조회","description":"신청 폼 필드 전체(대표 이미지는 presigned GET URL)에 심사 재료를 더해 조회한다 — 신청 계정 정보, 전 위치를 감싸는 노출 영역 사각형, 상태 이력이다. 노출 영역은 조회 시점 계산값이라 저장되지 않는다.\n\n관리자 조회에는 존재 은닉이 없다 — 없는 신청은 그대로 404(13430) 다.","operationId":"getSubmission_1","parameters":[{"name":"submissionId","in":"path","description":"조회할 신청 id","required":true,"schema":{"type":"integer","format":"int64"},"example":7}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminEventSubmissionDetailResponseDto"}}}}}}},"/api/admin/email-change-requests":{"get":{"tags":["관리자 행사 운영자 계정 (Admin Org Account)"],"summary":"아이디 변경 요청 목록 조회","description":"행사 운영자가 낸 아이디(공식 이메일) 변경 요청을 상태 필터 기준으로 접수 최신순 조회한다. 기본은 대기(PENDING) 요청이다. 항목에 현재 아이디와 바꾸려는 이메일이 나란히 실려 그대로 대조할 수 있고, 상태별 건수 3종이 필터와 무관하게 함께 온다.\n\n응답의 createdAt 은 승인·반려 요청에 되돌려 보내야 하는 검토 기준 시각이다 — 재요청은 같은 대기 행을 덮어쓰므로, 이 값으로 걸러야 본 적 없는 이메일을 승인하는 사고가 없다.\n\n지원하지 않는 status 는 400(1424), page 음수나 size 범위(1~100) 밖은 400(1425) 이다.","operationId":"getEmailChangeRequests","parameters":[{"name":"status","in":"query","description":"처리 상태 필터 (PENDING, APPROVED, REJECTED — 대소문자 무관)","required":false,"schema":{"type":"string","default":"PENDING"},"example":"PENDING"},{"name":"page","in":"query","description":"페이지 번호 (0부터)","required":false,"schema":{"type":"integer","format":"int32","default":0},"example":0},{"name":"size","in":"query","description":"페이지 크기 (1~100)","required":false,"schema":{"type":"integer","format":"int32","default":20},"example":20}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDtoAdminEmailChangeRequestListResponseDto"}}}}}}},"/api/friends/{userId}":{"delete":{"tags":["친구 (Friend)"],"summary":"친구 삭제","description":"친구 관계를 해소한다. 어느 쪽이든 삭제할 수 있고 즉시 양쪽 모두에서 사라진다. 대기 중 요청은 대상이 아니다.","operationId":"deleteFriend","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}}},"components":{"schemas":{"VideoReplaceRequestDto":{"type":"object","description":"영상 교체 요청. 파일만 바꾸려면 좌표를 생략한다. 좌표를 보내면 기존과 같은 격자여야 하며 다르면 GRID_MISMATCH로 거부된다.","properties":{"s3Key":{"type":"string","description":"새로 업로드한 영상의 S3 객체 키","example":"videos/2026/07/new-uuid.mp4","minLength":1},"lat":{"type":["number","null"],"format":"double","description":"위도 (선택). lng와 함께 보내거나 둘 다 생략","example":37.5665},"lng":{"type":["number","null"],"format":"double","description":"경도 (선택). lat과 함께 보내거나 둘 다 생략","example":126.978},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-17T14:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoVideoReplaceResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoReplaceResponseDto"}},"required":["data","developCode","message"]},"VideoReplaceResponseDto":{"type":"object","description":"영상 교체 응답. 교체 직후는 항상 재인코딩 대기(UPLOADED) 상태다.","properties":{"videoId":{"type":"integer","format":"int64","description":"교체된 영상 ID","example":1001},"processingStatus":{"type":"string","description":"영상 처리 상태 (교체 직후 UPLOADED)","example":"UPLOADED"}},"required":["processingStatus","videoId"]},"ProfileImageUpdateRequestDto":{"type":"object","description":"프로필 이미지 변경 확정 요청 (MSG-373)","properties":{"s3Key":{"type":"string","description":"presign 발급으로 받은 pending 키. 그 URL 로 업로드를 마친 뒤 그대로 전달한다.","example":"profiles/pending/42/3f0c1f2e-....jpg","minLength":1}},"required":["s3Key"]},"ApiResponseDtoUserProfileResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/UserProfileResponseDto"}},"required":["data","developCode","message"]},"UserProfileResponseDto":{"type":"object","description":"내 프로필 응답. 조회·닉네임 수정·프로필 이미지 변경·위치정보 동의 변경이 같은 형태를 반환한다.","properties":{"email":{"type":["string","null"],"description":"가입 이메일 — 이메일 가입 시 저장된 값. 카카오 가입은 이메일을 수집하지 않아 null (MSG-310)","example":"user@fillmap.dev"},"nickname":{"type":"string","description":"닉네임 — 카카오 로그인 시 카카오 닉네임이 자동 저장되며, 이후 수정 가능","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"프로필 이미지 공개 URL — 미설정이면 null 이고 기본 프로필 표시는 FE 몫이다 (MSG-373)","example":"https://fillmap-video-dev.s3.ap-northeast-2.amazonaws.com/profiles/original/42/uuid.jpg"},"createdAt":{"type":"string","format":"date-time","description":"가입 시각 — DB 저장값(UTC) 그대로다. \"2026.01.12\" 같은 표기는 FE 몫 (MSG-373)","example":"2026-01-12T03:24:11Z"},"locationConsent":{"type":"boolean","description":"위치기반서비스 이용 동의 여부 — 가입 직후는 false 다. 마지막 변경 시각은 서버에만 두고 응답에 싣지 않는다 (MSG-402 §D-6)","example":false},"role":{"type":"string","description":"사용자 역할 — 화면이 일반 사용자·행사 운영자·관리자 진입을 가르는 재료다 (MSG-496)","enum":["USER","ORG","ADMIN"],"example":"USER"}},"required":["createdAt","email","locationConsent","nickname","profileImageUrl","role"]},"NicknameUpdateRequestDto":{"type":"object","description":"닉네임 수정 요청","properties":{"nickname":{"type":"string","description":"새 닉네임 (2~20자)","example":"채우미","maxLength":20,"minLength":2}},"required":["nickname"]},"MarketingConsentUpdateRequestDto":{"type":"object","description":"마케팅 정보 수신 동의 변경 요청","properties":{"consented":{"type":"boolean","description":"true 면 동의, false 면 철회","example":true}},"required":["consented"]},"ApiResponseDtoConsentStatusResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ConsentStatusResponseDto"}},"required":["data","developCode","message"]},"ConsentStatusResponseDto":{"type":"object","description":"가입 약관 동의 상태. 조회·제출·마케팅 변경이 같은 형태를 반환한다.","properties":{"ageOver14":{"type":"boolean","description":"만 14세 이상 확인 여부 (필수). 자기 확인 체크 사실만 저장하며 생년월일은 수집하지 않는다","example":true},"serviceTerms":{"type":"boolean","description":"서비스 이용약관 동의 여부 (필수)","example":true},"privacyPolicy":{"type":"boolean","description":"개인정보 수집·이용 동의 여부 (필수)","example":true},"locationTerms":{"type":"boolean","description":"위치기반서비스 이용약관 동의 여부 (필수). 프로필 화면의 위치정보 사용 동의와 같은 한 값이며 철회할 수 없다 — 한 번 true 가 되면 되돌아가지 않는다","example":true},"marketing":{"type":"boolean","description":"마케팅 정보 수신 동의 여부 (선택). 가입 후에도 전용 API 로 켜고 끌 수 있다","example":false},"requiredCompleted":{"type":"boolean","description":"필수 4항목을 전부 동의했으면 true. false 면 클라이언트가 동의 게이트를 띄운다","example":true}},"required":["ageOver14","locationTerms","marketing","privacyPolicy","requiredCompleted","serviceTerms"]},"LocationConsentUpdateRequestDto":{"type":"object","description":"위치정보 사용 동의 켜기 요청","properties":{"consented":{"type":"boolean","description":"true 면 동의. 이 동의는 철회할 수 없어 false 는 1400 으로 거절된다","example":true}},"required":["consented"]},"ConsentSubmitRequestDto":{"type":"object","description":"가입 약관 동의 제출 요청. 필수 4항목은 true 여야 하고 마케팅만 선택이다.","properties":{"ageOver14":{"type":"boolean","description":"만 14세 이상 확인 (필수, true 만 허용)","example":true},"serviceTerms":{"type":"boolean","description":"서비스 이용약관 동의 (필수, true 만 허용)","example":true},"privacyPolicy":{"type":"boolean","description":"개인정보 수집·이용 동의 (필수, true 만 허용)","example":true},"locationTerms":{"type":"boolean","description":"위치기반서비스 이용약관 동의 (필수, true 만 허용)","example":true},"marketing":{"type":"boolean","description":"마케팅 정보 수신 동의 (선택). true·false 모두 유효하되 누락은 400 이다","example":false}},"required":["ageOver14","locationTerms","marketing","privacyPolicy","serviceTerms"]},"ApiResponseDtoEventVideoHelpfulResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoHelpfulResponseDto"}},"required":["data","developCode","message"]},"EventVideoHelpfulResponseDto":{"type":"object","description":"행사 영상 도움돼요 변경 결과","properties":{"helpfulCount":{"type":"integer","format":"int64","description":"처리 후 현재 도움돼요 수","example":12},"helpfulByMe":{"type":"boolean","description":"내가 누른 상태인지","example":true}},"required":["helpfulByMe","helpfulCount"]},"EventNotificationUpdateRequestDto":{"type":"object","description":"행사 알림 구독 토글","properties":{"enabled":{"type":"boolean","description":"구독 여부 — true 면 ON, false 면 OFF","example":true}},"required":["enabled"]},"ApiResponseDtoEventNotificationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventNotificationResponseDto"}},"required":["data","developCode","message"]},"EventNotificationResponseDto":{"type":"object","description":"행사 알림 구독 상태","properties":{"enabled":{"type":"boolean","description":"구독 여부 — 구독 행 존재이면서 회차가 예정·진행 중일 때만 true","example":true}},"required":["enabled"]},"FeaturedBadgeRequestDto":{"type":"object","description":"대표 뱃지 집합 교체 요청 — 배열 순서가 표시 순서, 빈 배열은 전부 해제","properties":{"badgeIds":{"type":"array","description":"대표로 지정할 뱃지 id 목록 (최대 2개, 순서 = 표시 순서)","example":[3,7],"items":{"type":"integer","format":"int64"},"maxItems":2,"minItems":0}},"required":["badgeIds"]},"ApiResponseDtoListFeaturedBadgeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FeaturedBadgeResponseDto"}}},"required":["data","developCode","message"]},"FeaturedBadgeResponseDto":{"type":"object","description":"적용된 대표 뱃지","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":3},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_50"},"name":{"type":"string","description":"표시명","example":"탐험가 II"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null},"rank":{"type":"integer","format":"int32","description":"표시 순서 (1·2)","example":1}},"required":["badgeId","code","iconUrl","name","rank"]},"VideoUploadRequestDto":{"type":"object","description":"S3 업로드 완료 후 영상 메타데이터 저장 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키","example":"videos/2026/07/uuid.mp4","minLength":1},"lat":{"type":"number","format":"double","description":"촬영 위치 위도 (격자 매핑에 사용)","example":37.5665},"lng":{"type":"number","format":"double","description":"촬영 위치 경도 (격자 매핑에 사용)","example":126.978},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-17T14:30:00Z"},"visibility":{"type":"string","description":"공개범위. PUBLIC, PRIVATE, FRIENDS 중 하나. 생략 시 PUBLIC","example":"PUBLIC"}},"required":["durationSec","lat","lng","recordedAt","s3Key"]},"ApiResponseDtoVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoUploadResponseDto"}},"required":["data","developCode","message"]},"CompletedMissionResponseDto":{"type":"object","description":"이번 업로드로 완료된 미션 스탬프","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 ID","example":3},"title":{"type":"string","description":"미션 제목","example":"성수 골목 코스"},"type":{"type":"string","description":"미션 유형 (COURSE/AREA/EVENT/THEME/CONTINUOUS)","example":"COURSE"}},"required":["missionId","title","type"]},"EarnedBadgeResponseDto":{"type":"object","description":"이번 행동으로 새로 획득한 뱃지","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":1},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_1"},"name":{"type":"string","description":"표시명","example":"첫 발자국"},"description":{"type":["string","null"],"description":"설명 — badges.description 은 NULL 허용 컬럼이다","example":"첫 격자를 수집했어요"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null}},"required":["badgeId","code","description","iconUrl","name"]},"VideoUploadResponseDto":{"type":"object","description":"영상 메타데이터 저장 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"매핑된 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 격자를 처음 점령(첫 방문)했는지 여부","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}},"completedMissions":{"type":"array","description":"이 업로드로 완료된 미션 스탬프 목록 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/CompletedMissionResponseDto"}},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속(해상 등)이거나 미판정이면 null","example":"서울특별시 강남구 역삼1동"}},"required":["completedMissions","gridId","newBadges","occupied","processingStatus","regionName","videoId","zoneCell","zoneName"]},"ReportCreateRequestDto":{"type":"object","description":"영상 신고 접수 요청. 사유 5종 중 하나와 선택적 상세 설명.","properties":{"reason":{"type":"string","description":"신고 사유. INAPPROPRIATE, PRIVACY, SPAM, COPYRIGHT, OTHER 중 하나 (대소문자 무관)","example":"INAPPROPRIATE","minLength":1},"detail":{"type":"string","description":"상세 설명. OTHER 사유는 필수, 나머지 사유는 선택. 최대 500자","example":"타인의 얼굴이 그대로 찍혀 있습니다","maxLength":500,"minLength":0}},"required":["reason"]},"ApiResponseDtoReportCreateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ReportCreateResponseDto"}},"required":["data","developCode","message"]},"ReportCreateResponseDto":{"type":"object","description":"영상 신고 접수 응답.","properties":{"reportId":{"type":"integer","format":"int64","description":"접수된 신고 ID","example":17},"status":{"type":"string","description":"신고 처리 상태. 접수 직후라 항상 PENDING","example":"PENDING"}},"required":["reportId","status"]},"PresignedUrlRequestDto":{"type":"object","description":"S3 업로드용 presigned URL 발급 요청","properties":{"extension":{"type":"string","description":"영상 파일 확장자 (점 없이)","example":"mp4","minLength":1},"contentType":{"type":"string","description":"영상 MIME 타입","example":"video/mp4","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 서버 상한 초과 시 거부","example":10485760},"purpose":{"type":"string","description":"발급 용도. 미지정(null)은 UPLOAD 와 동일. 하이라이트 선분석 원본은 HIGHLIGHT_PREVIEW 로 발급받아 전용 크기 상한(기본 2GiB)을 적용받는다","example":"HIGHLIGHT_PREVIEW","pattern":"UPLOAD|HIGHLIGHT_PREVIEW"}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoPresignedUrlResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/PresignedUrlResponseDto"}},"required":["data","developCode","message"]},"PresignedUrlResponseDto":{"type":"object","description":"presigned URL 발급 응답. uploadUrl로 S3에 직접 PUT 업로드 후, s3Key로 메타데이터 저장(POST /api/videos)을 호출한다.","properties":{"uploadUrl":{"type":"string","description":"S3에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/videos/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 이후 메타데이터 저장 요청에 그대로 전달한다.","example":"videos/2026/07/uuid.mp4"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":300}},"required":["expiresInSec","s3Key","uploadUrl"]},"HighlightPreviewRequestDto":{"type":"object","description":"하이라이트 선분석 요청 (MSG-351). 원본은 presign(purpose=HIGHLIGHT_PREVIEW)으로 먼저 올린다.","properties":{"s3Key":{"type":"string","description":"presign 으로 올린 원본의 pending 키. videos/pending/{내 userId}/ prefix 여야 한다","example":"videos/pending/42/550e8400-e29b-41d4-a716-446655440000.mp4","minLength":1}},"required":["s3Key"]},"ApiResponseDtoHighlightPreviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/HighlightPreviewResponseDto"}},"required":["data","developCode","message"]},"HighlightPreviewResponseDto":{"type":"object","description":"하이라이트 선분석 응답 (MSG-351). 결과는 저장되지 않는 임시 값이다 — 확정본의 하이라이트는 업로드 확정 후 블러 파이프라인이 따로 계산한다.","properties":{"highlights":{"type":"array","description":"[[시작초, 끝초], ...] 최대 3구간, 초는 소수점 둘째 자리. 배열 순서가 추천 우선순위(첫 요소가 최우선)다. 각 구간은 5초 이상이고 시작점끼리 5초 이상 벌어진다. 5초 미만 원본이거나 조건을 채우는 구간이 없으면 빈 배열 [] — 추천 없음이니 FE 는 추천 단계를 스킵한다","example":[[0.0,5.12],[10.0,16.4]],"items":{"type":"array","items":{"type":"number","format":"double"}}}},"required":["highlights"]},"ProfileImagePresignRequestDto":{"type":"object","description":"프로필 이미지 업로드용 presigned URL 발급 요청 (MSG-373)","properties":{"extension":{"type":"string","description":"이미지 파일 확장자 (점 없이). jpg, jpeg, png, webp — heic·heif 는 받지 않는다","example":"jpg","minLength":1},"contentType":{"type":"string","description":"이미지 MIME 타입. 확장자와 쌍이 맞아야 한다","example":"image/jpeg","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 5MB 초과 시 거부","example":1048576}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoProfileImagePresignResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ProfileImagePresignResponseDto"}},"required":["data","developCode","message"]},"ProfileImagePresignResponseDto":{"type":"object","description":"프로필 이미지 presigned URL 발급 응답. uploadUrl 로 S3 에 직접 PUT 업로드한 뒤 s3Key 로 변경 확정(PUT /api/users/me/profile-image)을 호출한다.","properties":{"uploadUrl":{"type":"string","description":"S3 에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/profiles/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 변경 확정 요청에 그대로 전달한다.","example":"profiles/pending/42/3f0c1f2e-....jpg"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":600}},"required":["expiresInSec","s3Key","uploadUrl"]},"RouteWalkPathRequestDto":{"type":"object","description":"보행 경로 조회 요청","properties":{"segments":{"type":"array","description":"추천 응답의 이웃 좌표쌍 목록 (1~8개 — 지점 상한 8이라 세그먼트 최대 7개에 출발지 구간 1개)","items":{"$ref":"#/components/schemas/SegmentDto"}}}},"SegmentDto":{"type":"object","description":"이웃 두 지점 사이 구간 (WGS84)","properties":{"startLat":{"type":"number","format":"double","description":"출발 위도","example":35.1587},"startLng":{"type":"number","format":"double","description":"출발 경도","example":129.1604},"endLat":{"type":"number","format":"double","description":"도착 위도","example":35.1631},"endLng":{"type":"number","format":"double","description":"도착 경도","example":129.1635}}},"ApiResponseDtoRouteWalkPathResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RouteWalkPathResponseDto"}},"required":["data","developCode","message"]},"PathPointDto":{"type":"object","description":"보행로 좌표 (WGS84)","properties":{"lat":{"type":"number","format":"double","description":"위도","example":35.1587},"lng":{"type":"number","format":"double","description":"경도","example":129.1604}},"required":["lat","lng"]},"RouteWalkPathResponseDto":{"type":"object","description":"보행 경로 조회 응답 — 요청과 같은 개수, 같은 순서","properties":{"segments":{"type":"array","description":"세그먼트별 보행 경로 결과","items":{"$ref":"#/components/schemas/WalkSegmentDto"}}},"required":["segments"]},"WalkSegmentDto":{"type":"object","description":"세그먼트 보행 경로","properties":{"resolved":{"type":"boolean","description":"보행 경로 확보 여부 — false 면 직선 폴백"},"path":{"type":["array","null"],"description":"보행로를 따르는 좌표열 (위도-경도 순). 실패 시 null","items":{"$ref":"#/components/schemas/PathPointDto"}},"distanceMeters":{"type":["integer","null"],"format":"int32","description":"실제 걷는 거리 (TMap totalDistance, 미터). 실패 시 null"}},"required":["distanceMeters","path","resolved"]},"OriginDto":{"type":"object","description":"출발 지점 좌표","properties":{"lat":{"type":"number","format":"double","description":"위도","example":35.115,"maximum":90.0,"minimum":-90.0},"lng":{"type":"number","format":"double","description":"경도","example":129.042,"maximum":180.0,"minimum":-180.0}},"required":["lat","lng"]},"RouteRecommendRequestDto":{"type":"object","description":"AI 경로 추천 요청","properties":{"text":{"type":"string","description":"하고 싶은 일 자연어 한 문장 (trim 후 1~500자)","example":"부산역 내려서 해운대에서 밥 먹고 축제도 보고 싶어","maxLength":500,"minLength":0},"viewport":{"$ref":"#/components/schemas/ViewportDto","description":"지금 보고 있는 지도 범위 (WGS84 사각형)"},"origin":{"$ref":"#/components/schemas/OriginDto","description":"출발 지점 좌표 (선택). 있으면 동선이 여기서 시작한다"}},"required":["text","viewport"]},"ViewportDto":{"type":"object","description":"WGS84 뷰포트 사각형","properties":{"minLat":{"type":"number","format":"double","description":"남서 위도","example":35.05},"minLng":{"type":"number","format":"double","description":"남서 경도","example":128.95},"maxLat":{"type":"number","format":"double","description":"북동 위도","example":35.25},"maxLng":{"type":"number","format":"double","description":"북동 경도","example":129.2}},"required":["maxLat","maxLng","minLat","minLng"]},"ApiResponseDtoRouteRecommendResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RouteRecommendResponseDto"}},"required":["data","developCode","message"]},"MentionedAreaDto":{"type":"object","description":"언급 지역 신호 — 지도 이동(MOVE)·축소(ZOOM_OUT) 제안의 이름·중심·범위 재료","properties":{"name":{"type":"string","description":"지역의 정식 표기 — 행정구역 매칭 단위 토큰 또는 구역 통칭(zones.name)","example":"부산광역시"},"centerLat":{"type":"number","format":"double","description":"지역 중심 위도 (WGS84) — 행정구역은 경계 무게중심, 구역은 외접 사각형 중점","example":35.1985},"centerLng":{"type":"number","format":"double","description":"지역 중심 경도 (WGS84)","example":129.0538},"minLat":{"type":"number","format":"double","description":"외접 사각형 남단 위도 (WGS84)","example":35.0512},"minLng":{"type":"number","format":"double","description":"외접 사각형 서단 경도 (WGS84)","example":128.7602},"maxLat":{"type":"number","format":"double","description":"외접 사각형 북단 위도 (WGS84)","example":35.3891},"maxLng":{"type":"number","format":"double","description":"외접 사각형 동단 경도 (WGS84)","example":129.2723},"kind":{"type":"string","description":"신호 종류 — MOVE(뷰포트와 안 겹침, 이동 제안)·ZOOM_OUT(겹치지만 뚜렷이 좁음, 축소 제안)","example":"MOVE"}},"required":["centerLat","centerLng","kind","maxLat","maxLng","minLat","minLng","name"]},"RoutePointDto":{"type":"object","description":"추천 지점","properties":{"order":{"type":"integer","format":"int32","description":"방문 순서 (1부터 연속)","example":1},"name":{"type":"string","description":"지점 이름 (원문 그대로 — AI 로 보낼 때만 100자 절단)","example":"해운대 빛축제"},"kind":{"type":"string","description":"지점 종류 — MISSION_FESTIVAL·MISSION_POPUP·MISSION_COURSE·EVENT·PLACE. FE 마커 분기용","example":"MISSION_FESTIVAL"},"lat":{"type":"number","format":"double","description":"대표 좌표 위도 (WGS84)","example":35.1587},"lng":{"type":"number","format":"double","description":"대표 좌표 경도 (WGS84)","example":129.1604},"gridId":{"type":"string","description":"격자 ID — 대표 좌표를 GridEncoder 로 즉석 계산","example":"16941_11439"},"zoneName":{"type":["string","null"],"description":"표시명 구역 이름 (MSG-341). 구역 밖이면 zoneCell 과 쌍으로 null"},"zoneCell":{"type":["string","null"],"description":"표시명 구역 셀","example":"B-3"},"regionName":{"type":["string","null"],"description":"행정동 폴백 재료 (MSG-349 정책 동일). 무귀속이면 null"},"reason":{"type":"string","description":"추천 이유 한 줄 — AI explain 응답의 reasons 항목 그대로 (FR-ROUTE-05)"},"missionId":{"type":["integer","null"],"format":"int64","description":"미션 후보면 미션 id — FE 가 미션 상세로 잇는 데 쓴다"},"occurrenceId":{"type":["integer","null"],"format":"int64","description":"행사 후보면 회차 id"}},"required":["gridId","kind","lat","lng","missionId","name","occurrenceId","order","reason","regionName","zoneCell","zoneName"]},"RouteRecommendResponseDto":{"type":"object","description":"AI 경로 추천 응답","properties":{"points":{"type":"array","description":"방문 순서대로 정렬된 지점 목록 (최대 8개)","items":{"$ref":"#/components/schemas/RoutePointDto"}},"notice":{"type":["string","null"],"description":"안내 문구 — 후보 부족(0~2개)이면 부족 안내, 여행과 무관한 문장(MSG-513)이면 무관 안내. 지점 3개 이상 정상 추천이면 null"},"mentionedArea":{"anyOf":[{"$ref":"#/components/schemas/MentionedAreaDto"},{"type":"null"}],"description":"언급 지역 신호 (MSG-468) — 문장이 화면 밖 지역을 말했으면 이동·축소 제안 재료가 실린다. 무신호(지역 무언급·동명 다수·대조 실패·충분히 담김)가 기본값"},"summary":{"type":["string","null"],"description":"동선 전체의 종합 추천 이유(FR-ROUTE-20). 사용자 문장을 근거로 이번 지점 구성을 설명한다. 빈 목록 응답(후보 없음, 무관 문장, 도보 절단)은 null이고 notice가 그 자리를 맡는다"}},"required":["mentionedArea","notice","points","summary"]},"EventSubmissionAreaRectDto":{"type":"object","description":"위치 영역 사각형 (격자 인덱스). 위치 하나의 합집합은 최대 81칸이다.","properties":{"minGridY":{"type":"integer","format":"int32","description":"격자 행 인덱스 최소","example":16859},"maxGridY":{"type":"integer","format":"int32","description":"격자 행 인덱스 최대","example":16861},"minGridX":{"type":"integer","format":"int32","description":"격자 열 인덱스 최소","example":11509},"maxGridX":{"type":"integer","format":"int32","description":"격자 열 인덱스 최대","example":11515}},"required":["maxGridX","maxGridY","minGridX","minGridY"]},"EventSubmissionCreateRequestDto":{"type":"object","description":"행사 등재 신청 제출 요청","properties":{"type":{"type":"string","description":"등록 유형 — FESTIVAL(지역축제)·POPUP(팝업스토어)·EVENT(이벤트 참여형)","enum":["FESTIVAL","POPUP","EVENT"],"example":"FESTIVAL"},"parentOccurrenceId":{"type":["integer","null"],"format":"int64","description":"참여할 승인 이벤트 회차 id — EVENT 전용 필수. 승인 이벤트 목록 응답의 occurrenceId 를 그대로 넣는다. 없는 회차면 13440, 이미 종료된 회차면 13441, 다른 유형에 실려 오면 13439","example":1},"title":{"type":"string","description":"축제명 / 팝업명","example":"부산불꽃축제","maxLength":100,"minLength":0},"organizerName":{"type":"string","description":"주최 기관 / 브랜드·운영사","example":"부산문화관광축제조직위원회","maxLength":100,"minLength":0},"startsOn":{"type":"string","format":"date","description":"행사 시작일 (KST 날짜)","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일 (KST 날짜). 오늘 이전이면 13433","example":"2026-11-07"},"operatingHours":{"type":"string","description":"운영 시간 — POPUP 전용 필수. FESTIVAL 에 실려 오면 13439","example":"11:00 ~ 20:00","maxLength":100,"minLength":0},"programDescription":{"type":"string","description":"주요 프로그램 — FESTIVAL 전용 필수. 다른 유형에 실려 오면 13439","example":"멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영","maxLength":2000,"minLength":10},"participationMethod":{"type":"string","description":"참여 방식 — EVENT 전용 필수. 다른 유형에 실려 오면 13439","example":"부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다","maxLength":2000,"minLength":10},"description":{"type":"string","description":"행사 소개","example":"광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제","maxLength":2000,"minLength":10},"imageS3Key":{"type":"string","description":"대표 이미지의 pending S3 키. presign 발급 응답의 s3Key 를 그대로 넣는다.","example":"event-submissions/pending/12/3f0c1f2e-....jpg","minLength":1},"locations":{"type":"array","description":"행사 위치 목록. 1개 이상 20개 이하이고 이름 필드가 없다.","items":{"$ref":"#/components/schemas/EventSubmissionLocationRequestDto"}}},"required":["description","endsOn","imageS3Key","organizerName","startsOn","title","type"]},"EventSubmissionLocationRequestDto":{"type":"object","description":"신청 위치 — 영역 사각형 목록만 담는다 (이름 없음)","properties":{"areaRects":{"type":"array","description":"영역 사각형 목록. 겹쳐도 되고 합집합 크기로 81칸 상한을 판정한다.","items":{"$ref":"#/components/schemas/EventSubmissionAreaRectDto"}}}},"ApiResponseDtoEventSubmissionSubmitResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventSubmissionSubmitResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionSubmitResponseDto":{"type":"object","description":"신청 접수 결과","properties":{"id":{"type":"integer","format":"int64","description":"신청 id","example":7},"submissionNo":{"type":"string","description":"신청 번호 — FM-{KST 연도}-{4자리 순번}","example":"FM-2026-0007"},"status":{"type":"string","description":"신청 상태","example":"IN_REVIEW"}},"required":["id","status","submissionNo"]},"EventSubmissionImagePresignRequestDto":{"type":"object","description":"행사 신청 대표 이미지 업로드용 presigned URL 발급 요청 (MSG-498)","properties":{"extension":{"type":"string","description":"이미지 파일 확장자 (점 없이). jpg, jpeg, png 만 — 시안 문구가 \"JPG 또는 PNG\"라 webp 는 받지 않는다","example":"jpg","minLength":1},"contentType":{"type":"string","description":"이미지 MIME 타입. 확장자와 쌍이 맞아야 한다","example":"image/jpeg","minLength":1},"contentLength":{"type":"integer","format":"int64","description":"업로드할 파일 크기(바이트). 10MB 초과 시 거부","example":1048576}},"required":["contentLength","contentType","extension"]},"ApiResponseDtoEventSubmissionImagePresignResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventSubmissionImagePresignResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionImagePresignResponseDto":{"type":"object","description":"행사 신청 대표 이미지 presigned URL 발급 응답. uploadUrl 로 S3 에 직접 PUT 업로드한 뒤 s3Key 를 신청 제출·재제출 요청의 imageS3Key 로 전달한다.","properties":{"uploadUrl":{"type":"string","description":"S3 에 직접 PUT 업로드할 presigned URL","example":"https://bucket.s3.amazonaws.com/event-submissions/pending/..."},"s3Key":{"type":"string","description":"업로드 대상 S3 객체 키. 제출·재제출 요청에 그대로 전달한다.","example":"event-submissions/pending/12/3f0c1f2e-....jpg"},"expiresInSec":{"type":"integer","format":"int64","description":"presigned URL 유효 시간(초)","example":600}},"required":["expiresInSec","s3Key","uploadUrl"]},"OrgEmailChangeRequestDto":{"type":"object","description":"아이디(공식 이메일) 변경 요청","properties":{"requestedEmail":{"type":"string","format":"email","description":"바꾸려는 공식 이메일","example":"new-organizer@fillmap.dev","maxLength":255,"minLength":0}},"required":["requestedEmail"]},"OrgAccountRequestCreateRequestDto":{"type":"object","description":"행사 운영자 계정 발급 요청 (비로그인 공개 폼)","properties":{"orgName":{"type":"string","description":"기관명","example":"부산진구청","maxLength":100,"minLength":0},"contactName":{"type":"string","description":"담당자 이름 (2~20자). 승인 시 계정 담당자 이름이 되므로 계정 설정과 같은 제약이다","example":"김담당","maxLength":20,"minLength":2},"contactPhone":{"type":"string","description":"담당자 연락처. 숫자로 시작하고 끝나는 숫자·하이픈 9~20자","example":"010-1234-5678","minLength":1,"pattern":"^[0-9][0-9-]{7,18}[0-9]$"},"email":{"type":"string","format":"email","description":"공식 이메일. 승인 시 계정 아이디이자 초기 비밀번호를 받을 주소다","example":"event@busanjin.go.kr","maxLength":255,"minLength":0},"eventName":{"type":"string","description":"예정 행사명","example":"서면 겨울 축제","maxLength":200,"minLength":0},"content":{"type":"string","description":"요청 내용","example":"12월 서면 일대 겨울 축제 등재를 위해 계정을 신청합니다.","maxLength":2000,"minLength":0}},"required":["contactName","contactPhone","content","email","eventName","orgName"]},"PushTokenRequestDto":{"type":"object","description":"FCM 푸시 토큰 등록/갱신 요청 — 같은 토큰 재등록은 충돌 없이 현재 계정으로 갱신된다","properties":{"fcmToken":{"type":"string","description":"FCM 디바이스 토큰 (push_tokens PK, 최대 512자)","example":"fcm-token-abc123","maxLength":512,"minLength":0},"platform":{"type":"string","description":"플랫폼 — IOS·ANDROID·WEB (대소문자 무시)","example":"WEB","minLength":1},"appVersion":{"type":"string","description":"앱 버전 (선택, 최대 20자)","example":"1.0.0","maxLength":20,"minLength":0}},"required":["fcmToken","platform"]},"MissionVideoUploadRequestDto":{"type":"object","description":"미션 경유 영상 업로드 확정 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키. 같은 키로 다시 보내면 멱등하게 처리된다","example":"videos/pending/42/6f1c1f0e-1d2b-4a5a-9f0e-2b3c4d5e6f70.mp4","minLength":1},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각. 미래 시각은 거부되고(단말 시계 오차 5분 허용), 미션 기간 밖도 거부된다","example":"2026-10-06T12:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoMissionVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/MissionVideoUploadResponseDto"}},"required":["data","developCode","message"]},"MissionVideoUploadResponseDto":{"type":"object","description":"미션 경유 영상 업로드 확정 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"서버가 정한 그 미션의 대표 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 대표 격자를 처음 점령했는지 여부. 재시도 응답은 항상 false","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}},"completedMissions":{"type":"array","description":"이 업로드로 새로 발급된 스탬프 — 이미 받았거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/CompletedMissionResponseDto"}}},"required":["completedMissions","gridId","newBadges","occupied","processingStatus","videoId"]},"FriendRequestCreateRequestDto":{"type":"object","description":"친구 요청 생성 요청","properties":{"friendCode":{"type":"string","description":"상대의 고정 친구 코드","example":"AB3DE7GH","minLength":1}},"required":["friendCode"]},"ApiResponseDtoFriendRequestCreateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendRequestCreateResponseDto"}},"required":["data","developCode","message"]},"FriendRequestCreateResponseDto":{"type":"object","description":"친구 요청 생성 응답","properties":{"status":{"type":"string","description":"PENDING = 요청이 등록돼 상대 수락 대기, ACCEPTED = 상대가 먼저 보낸 요청이 있어 즉시 친구 성립(자동 수락 — FR-8). FE 는 이 값으로 \"요청 보냄\"과 \"친구가 됐어요\" 화면을 구분한다.","enum":["PENDING","ACCEPTED"]}},"required":["status"]},"EventVideoCommentRequestDto":{"type":"object","description":"행사 영상 댓글 작성·수정 요청","properties":{"content":{"type":"string","description":"댓글 본문 (1~500자)","example":"저도 어제 다녀왔어요","maxLength":500,"minLength":0}},"required":["content"]},"ApiResponseDtoEventVideoCommentResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoCommentResponseDto"}},"required":["data","developCode","message"]},"EventVideoCommentResponseDto":{"type":"object","description":"행사 영상 댓글","properties":{"commentId":{"type":"integer","format":"int64","description":"댓글 ID","example":3021},"authorId":{"type":"integer","format":"int64","description":"작성자 사용자 ID","example":7007},"authorNickname":{"type":"string","description":"작성자 닉네임","example":"필맵러"},"content":{"type":"string","description":"댓글 본문","example":"저도 어제 다녀왔어요"},"createdAt":{"type":"string","format":"date-time","description":"작성 시각","example":"2026-10-06T12:30:00Z"}},"required":["authorId","authorNickname","commentId","content","createdAt"]},"EventVideoUploadRequestDto":{"type":"object","description":"행사 영상 업로드 확정 요청","properties":{"s3Key":{"type":"string","description":"presigned 발급 때 받은 S3 객체 키. 같은 키로 다시 보내면 멱등하게 처리된다","example":"videos/pending/42/6f1c1f0e-1d2b-4a5a-9f0e-2b3c4d5e6f70.mp4","minLength":1},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초). 1~30초","example":15,"maximum":30,"minimum":1},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각. 미래 시각은 거부된다(단말 시계 오차 5분 허용)","example":"2026-10-06T12:30:00Z"}},"required":["durationSec","recordedAt","s3Key"]},"ApiResponseDtoEventVideoUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoUploadResponseDto"}},"required":["data","developCode","message"]},"EventVideoUploadResponseDto":{"type":"object","description":"행사 영상 업로드 확정 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"생성된 영상 ID","example":1001},"gridId":{"type":"string","description":"서버가 지정한 대표 격자 ID","example":"19422_9582"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"UPLOADED"},"occupied":{"type":"boolean","description":"이 업로드로 대표 격자를 처음 점령했는지 여부. 재시도 응답은 항상 false","example":true},"newBadges":{"type":"array","description":"이 업로드로 새로 획득한 뱃지 목록 — 없거나 재시도 응답이면 빈 배열","items":{"$ref":"#/components/schemas/EarnedBadgeResponseDto"}}},"required":["gridId","newBadges","occupied","processingStatus","videoId"]},"SignupRequestDto":{"type":"object","description":"이메일 회원가입 요청","properties":{"email":{"type":"string","format":"email","description":"이메일 (최대 255자, 중복 불가)","example":"user@fillmap.dev","maxLength":255,"minLength":0},"password":{"type":"string","description":"비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"},"nickname":{"type":"string","description":"닉네임 (2~20자)","example":"채우미","maxLength":20,"minLength":2}},"required":["email","nickname","password"]},"ApiResponseDtoSignupResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/SignupResponseDto"}},"required":["data","developCode","message"]},"SignupResponseDto":{"type":"object","description":"회원가입 성공 응답 — 생성된 사용자 정보","properties":{"id":{"type":"integer","format":"int64","description":"생성된 사용자 ID","example":1},"email":{"type":"string","description":"가입 이메일","example":"user@fillmap.dev"},"nickname":{"type":"string","description":"닉네임","example":"채우미"},"createdAt":{"type":"string","format":"date-time","description":"가입 시각","example":"2026-07-17T20:11:03Z"}},"required":["createdAt","email","id","nickname"]},"ReissueRequestDto":{"type":"object","description":"토큰 재발급 요청. 웹은 리프레시 토큰이 쿠키(refreshToken)로 전송되므로 body 를 생략할 수 있다.","properties":{"refreshToken":{"type":"string","description":"앱(X-Client-Type: app) 클라이언트의 리프레시 토큰. 웹은 쿠키를 사용하므로 생략.","example":"eyJhbGciOiJIUzI1NiJ9..."}}},"ApiResponseDtoReissueResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ReissueResponseDto"}},"required":["data","developCode","message"]},"ReissueResponseDto":{"type":"object","description":"토큰 재발급 성공 응답","properties":{"accessToken":{"type":"string","description":"새로 발급된 JWT 액세스 토큰.","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":["string","null"],"description":"회전된 새 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 재설정되므로 null 이다.","example":"eyJhbGciOiJIUzI1NiJ9..."}},"required":["accessToken","refreshToken"]},"PasswordResetConfirmRequestDto":{"type":"object","description":"비밀번호 재설정 확정 요청","properties":{"token":{"type":"string","description":"재설정 링크의 token 쿼리 값","example":"9pQ2f7Zk...","minLength":1},"newPassword":{"type":"string","description":"새 비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"}},"required":["newPassword","token"]},"PasswordResetRequestDto":{"type":"object","description":"비밀번호 재설정 링크 요청","properties":{"email":{"type":"string","format":"email","description":"계정 이메일(아이디)","example":"organizer@fillmap.dev","maxLength":255,"minLength":0}},"required":["email"]},"PasswordInitialRequestDto":{"type":"object","description":"초기 비밀번호 설정 요청","properties":{"newPassword":{"type":"string","description":"새 비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"}},"required":["newPassword"]},"PasswordChangeRequestDto":{"type":"object","description":"비밀번호 변경 요청","properties":{"currentPassword":{"type":"string","description":"현재 비밀번호. 초기 비밀번호 상태면 발급받은 그 값이다","example":"Initial1234","minLength":1},"newPassword":{"type":"string","description":"새 비밀번호. 영문과 숫자를 각각 하나 이상 포함한 8~64자","example":"Fillmap1234","maxLength":64,"minLength":8,"pattern":"^(?=.*[A-Za-z])(?=.*\\d).+$"}},"required":["currentPassword","newPassword"]},"OidcLoginRequestDto":{"type":"object","description":"소셜(OIDC) 로그인 요청","properties":{"idToken":{"type":"string","description":"소셜 제공자(카카오·애플)에서 발급받은 OIDC ID Token","example":"eyJraWQiOiI...","minLength":1},"nonce":{"type":"string","description":"앱이 요청마다 만든 nonce 원문 (APPLE 필수, 카카오는 무시). 앱은 이 값의 SHA-256 16진 소문자를 애플 로그인 시트에 넘기고 원문을 서버에 보낸다 — 원문을 시트에 넘기면 대조가 항상 실패한다(2421)","example":"3f9a1c..."},"authorizationCode":{"type":"string","description":"애플 authorizationCode (APPLE 필수). 첫 로그인인지 가리지 말고 매번 보낸다 — 서버가 계정이 없을 때만 교환한다. 5분 안에 1회만 교환 가능","example":"c8a3b1..."},"fullName":{"type":"string","description":"애플이 첫 승인에만 주는 이름을 한 문자열로 조립한 값 (선택). 계정을 새로 만들 때만 닉네임으로 쓰고, 2~20자 밖이면 기본 닉네임(필맵러+4자리)으로 대체한다","example":"김필맵"}},"required":["idToken"]},"ApiResponseDtoLoginResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/LoginResponseDto"}},"required":["data","developCode","message"]},"LoginResponseDto":{"type":"object","description":"로그인 성공 응답","properties":{"accessToken":{"type":"string","description":"발급된 JWT 액세스 토큰. 이후 요청 Authorization 헤더에 'Bearer {토큰}'으로 넣는다.","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":["string","null"],"description":"발급된 리프레시 토큰. 앱(X-Client-Type: app)만 값이 채워지고, 웹은 HttpOnly 쿠키(Set-Cookie)로 내려가므로 null 이다.","example":"eyJhbGciOiJIUzI1NiJ9..."},"role":{"type":"string","description":"로그인한 사용자의 역할. 화면이 일반 사용자·행사 운영자·관리자 진입을 가르는 재료다 (MSG-496).","enum":["USER","ORG","ADMIN"],"example":"USER"}},"required":["accessToken","refreshToken","role"]},"KakaoCodeLoginRequestDto":{"type":"object","description":"카카오 인가 코드 로그인 요청 (웹). 카카오 콜백으로 받은 코드를 서버가 ID Token 으로 교환한다.","properties":{"code":{"type":"string","description":"카카오 콜백 쿼리로 받은 1회용 인가 코드","example":"vBv8oXbeLnDF2mkw...","minLength":1},"redirectUri":{"type":"string","description":"인가 요청에 사용한 redirect URI 그대로. 카카오 콘솔 등록값과 정확히 일치해야 한다.","example":"http://localhost:5173/oauth/kakao/callback","minLength":1}},"required":["code","redirectUri"]},"LogoutRequestDto":{"type":"object","description":"로그아웃 요청 (선택 body) — fcmToken 이 있으면 세션 삭제와 함께 해당 FCM 푸시 토큰도 정리된다","properties":{"fcmToken":{"type":"string","description":"정리할 FCM 토큰 (선택)","example":"fcm-token-abc123"}}},"LoginRequestDto":{"type":"object","description":"이메일/비밀번호 로그인 요청","properties":{"email":{"type":"string","format":"email","description":"가입한 이메일","example":"user@fillmap.dev","minLength":1},"password":{"type":"string","description":"비밀번호 (영문+숫자 포함 8~64자)","example":"Fillmap1234","minLength":1}},"required":["email","password"]},"DevSocialLoginRequestDto":{"type":"object","description":"[로컬/dev 전용] 소셜 로그인 모의 요청 — 실제 소셜 ID Token 없이 (provider, oid)로 로그인/가입한다.","properties":{"provider":{"type":"string","description":"소셜 제공자 (기본 KAKAO, APPLE 가능 — 애플 왕복 없이 계정만 만든다)","example":"KAKAO"},"oid":{"type":"string","description":"소셜 고유 식별자(oid). 같은 값이면 같은 사용자로 재로그인된다.","example":"dev-kakao-1","minLength":1},"email":{"type":"string","description":"이메일 (선택). 없으면 {oid}@dev.local","example":"kakaouser@dev.local"},"nickname":{"type":"string","description":"닉네임 (선택). 없으면 dev-{oid}","example":"카카오테스터"}},"required":["oid"]},"AdminVideoUnblindResponseDto":{"type":"object","description":"블라인드 해제 결과 — 복구된 영상 상태.","properties":{"videoId":{"type":"integer","format":"int64","description":"해제된 영상 ID","example":1042},"status":{"type":"string","description":"해제 후 영상 상태 — 성공이면 항상 ACTIVE","enum":["ACTIVE","BLINDED","DELETED"],"example":"ACTIVE"}},"required":["status","videoId"]},"ApiResponseDtoAdminVideoUnblindResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminVideoUnblindResponseDto"}},"required":["data","developCode","message"]},"AdminReportProcessResponseDto":{"type":"object","description":"신고 승인·기각 처리 결과 — 종결된 신고 상태와 처리 후 영상 상태.","properties":{"reportId":{"type":"integer","format":"int64","description":"처리된 신고 ID","example":7},"status":{"type":"string","description":"처리 후 신고 상태 — 승인이면 RESOLVED, 기각이면 REJECTED","enum":["PENDING","REVIEWING","RESOLVED","REJECTED"],"example":"RESOLVED"},"videoId":{"type":"integer","format":"int64","description":"신고 대상 영상 ID","example":1042},"videoStatus":{"type":"string","description":"처리 후 영상 상태 — 승인의 전이 생략 케이스(FR-5)를 이 값으로 구분한다","enum":["ACTIVE","BLINDED","DELETED"],"example":"BLINDED"},"reviewedAt":{"type":"string","format":"date-time","description":"처리 시각","example":"2026-08-06T11:00:00Z"}},"required":["reportId","reviewedAt","status","videoId","videoStatus"]},"ApiResponseDtoAdminReportProcessResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReportProcessResponseDto"}},"required":["data","developCode","message"]},"OrgAccountCreateRequestDto":{"type":"object","description":"행사 운영자 계정 직접 발급 요청","properties":{"orgName":{"type":"string","description":"기관명","example":"부산진구청","maxLength":100,"minLength":0},"contactName":{"type":"string","description":"담당자 이름 (2~20자)","example":"김담당","maxLength":20,"minLength":2},"email":{"type":"string","format":"email","description":"공식 이메일. 계정 아이디이자 초기 비밀번호를 받을 주소다","example":"event@busanjin.go.kr","maxLength":255,"minLength":0},"contactPhone":{"type":"string","description":"담당자 연락처 (선택). 값이 있으면 숫자로 시작하고 끝나는 숫자·하이픈 9~20자","example":"010-1234-5678","pattern":"^[0-9][0-9-]{7,18}[0-9]$"}},"required":["contactName","email","orgName"]},"ApiResponseDtoOrgAccountIssueResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OrgAccountIssueResponseDto"}},"required":["data","developCode","message"]},"OrgAccountIssueResponseDto":{"type":"object","description":"계정 발급 결과","properties":{"userId":{"type":"integer","format":"int64","description":"발급된 계정 id","example":42},"emailSent":{"type":"boolean","description":"초기 비밀번호 메일 발송 성공 여부. true 는 SES 접수까지의 성공이고 배달 확인은 아니다","example":true}},"required":["emailSent","userId"]},"ApiResponseDtoOrgAccountResendResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OrgAccountResendResponseDto"}},"required":["data","developCode","message"]},"OrgAccountResendResponseDto":{"type":"object","description":"초기 비밀번호 재발송 결과","properties":{"emailSent":{"type":"boolean","description":"메일 발송 성공 여부. true 는 SES 접수까지의 성공이고 배달 확인은 아니다","example":true}},"required":["emailSent"]},"OrgAccountRequestRejectRequestDto":{"type":"object","description":"계정 발급 요청 반려 요청","properties":{"reason":{"type":"string","description":"반려 사유 (최대 500자). 요청자에게 발송되는 반려 안내 메일에 그대로 실린다","example":"기관 확인 서류가 누락되었습니다","maxLength":500,"minLength":0},"updatedAt":{"type":"string","format":"date-time","description":"상세 조회로 받은 마지막 접수 시각. 값이 다르면 검토 이후 요청이 바뀐 것이라 반려가 거부된다"}},"required":["reason","updatedAt"]},"ApiResponseDtoOrgAccountRequestRejectResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OrgAccountRequestRejectResponseDto"}},"required":["data","developCode","message"]},"OrgAccountRequestRejectResponseDto":{"type":"object","description":"계정 발급 요청 반려 결과","properties":{"emailSent":{"type":"boolean","description":"반려 안내 메일 발송 성공 여부. true 는 SES 접수까지의 성공이고 배달 확인은 아니다","example":true}},"required":["emailSent"]},"OrgAccountRequestApproveRequestDto":{"type":"object","description":"계정 발급 요청 승인 요청","properties":{"updatedAt":{"type":"string","format":"date-time","description":"상세 조회로 받은 마지막 접수 시각. 값이 다르면 검토 이후 요청이 바뀐 것이라 승인이 거부된다"}},"required":["updatedAt"]},"AdminEventUnpublishRequestDto":{"type":"object","description":"행사 노출 중지 요청","properties":{"reason":{"type":"string","description":"중지 사유 — 행사 운영자에게 그대로 발송된다","example":"행사가 취소되어 노출을 중지합니다","minLength":1}},"required":["reason"]},"AdminEventUnpublishResponseDto":{"type":"object","description":"행사 노출 중지 결과","properties":{"submissionId":{"type":"integer","format":"int64","description":"중지한 승인 행사 식별자 (= 신청 id)","example":7},"unpublishedAt":{"type":"string","format":"date-time","description":"중지 시각 (UTC)","example":"2026-08-30T02:11:00Z"},"emailSent":{"type":"boolean","description":"사유 통지 메일 발송 성공 여부 — false 여도 중지는 유지된다","example":true}},"required":["emailSent","submissionId","unpublishedAt"]},"ApiResponseDtoAdminEventUnpublishResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminEventUnpublishResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionRejectRequestDto":{"type":"object","description":"행사 등재 신청 반려 요청","properties":{"reasonCodes":{"type":"array","description":"반려 항목 코드 1개 이상 (PERIOD, AREA, IMAGE, INFO — 중복 불가)","example":["AREA","INFO"],"items":{"type":"string"}},"reasonText":{"type":"string","description":"반려 사유 본문","example":"신청 영역이 행사 실제 범위보다 넓습니다","minLength":1}},"required":["reasonCodes","reasonText"]},"ApiResponseDtoEventSubmissionApproveResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventSubmissionApproveResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionApproveResponseDto":{"type":"object","description":"행사 등재 신청 승인 결과","properties":{"submissionId":{"type":"integer","format":"int64","description":"승인한 신청 id","example":7},"approvalNo":{"type":"string","description":"부여된 승인 번호","example":"APR-2026-0001"},"status":{"type":"string","description":"전이 후 상태","example":"APPROVED"}},"required":["approvalNo","status","submissionId"]},"EmailChangeRejectRequestDto":{"type":"object","description":"아이디 변경 요청 반려","properties":{"requestedAt":{"type":"string","format":"date-time","description":"검토한 요청의 접수 시각 (목록의 createdAt 을 그대로)","example":"2026-08-28T02:00:00Z"},"reason":{"type":"string","description":"반려 사유","example":"기관 도메인이 아닌 이메일이라 반려합니다","minLength":1}},"required":["reason","requestedAt"]},"EmailChangeApproveRequestDto":{"type":"object","description":"아이디 변경 요청 승인","properties":{"requestedAt":{"type":"string","format":"date-time","description":"검토한 요청의 접수 시각 (목록의 createdAt 을 그대로)","example":"2026-08-28T02:00:00Z"}},"required":["requestedAt"]},"ApiResponseDtoEmailChangeApproveResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EmailChangeApproveResponseDto"}},"required":["data","developCode","message"]},"EmailChangeApproveResponseDto":{"type":"object","description":"아이디 변경 승인 결과","properties":{"requestId":{"type":"integer","format":"int64","description":"승인한 요청 id","example":3},"email":{"type":"string","description":"교체된 새 아이디(로그인 이메일)","example":"festival@busanjin.go.kr"},"emailSent":{"type":"boolean","description":"새 이메일로 보낸 통지 성공 여부 — false 여도 교체는 유지된다","example":true}},"required":["email","emailSent","requestId"]},"VideoVisibilityRequestDto":{"type":"object","description":"영상 공개 범위 전환 요청. PUBLIC · PRIVATE · FRIENDS.","properties":{"visibility":{"type":"string","description":"공개 범위. PUBLIC, PRIVATE, FRIENDS 중 하나 (대소문자 무관)","example":"PUBLIC","minLength":1}},"required":["visibility"]},"ApiResponseDtoVideoVisibilityResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoVisibilityResponseDto"}},"required":["data","developCode","message"]},"VideoVisibilityResponseDto":{"type":"object","description":"영상 공개 범위 전환 응답. 전환 후 공개 범위를 담는다.","properties":{"videoId":{"type":"integer","format":"int64","description":"전환된 영상 ID","example":1042},"visibility":{"type":"string","description":"전환 후 공개 범위 (PUBLIC, PRIVATE, FRIENDS 중 하나)","example":"PUBLIC"}},"required":["videoId","visibility"]},"OrgProfileUpdateRequestDto":{"type":"object","description":"담당자 정보 수정 요청","properties":{"contactName":{"type":"string","description":"담당자 이름 (2~20자). users.nickname 에 저장되므로 가입 닉네임과 같은 제약이다","example":"김담당","maxLength":20,"minLength":2},"contactPhone":{"type":"string","description":"담당자 연락처. 숫자로 시작하고 끝나는 숫자·하이픈 9~20자","example":"010-1234-5678","minLength":1,"pattern":"^[0-9][0-9-]{7,18}[0-9]$"}},"required":["contactName","contactPhone"]},"ApiResponseDtoOrgProfileResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OrgProfileResponseDto"}},"required":["data","developCode","message"]},"OrgProfileResponseDto":{"type":"object","description":"행사 운영자 계정 설정 응답","properties":{"email":{"type":"string","description":"아이디(공식 이메일). 읽기 전용","example":"organizer@fillmap.dev"},"contactName":{"type":"string","description":"담당자 이름","example":"김담당"},"contactPhone":{"type":["string","null"],"description":"담당자 연락처. 아직 입력한 적이 없으면 null","example":"010-1234-5678"}},"required":["contactName","contactPhone","email"]},"EventSubmissionUpdateRequestDto":{"type":"object","description":"반려본 수정 재제출 요청 — 유형을 뺀 전체 교체","properties":{"title":{"type":"string","description":"축제명 / 팝업명","example":"부산불꽃축제","maxLength":100,"minLength":0},"organizerName":{"type":"string","description":"주최 기관 / 브랜드·운영사","example":"부산문화관광축제조직위원회","maxLength":100,"minLength":0},"startsOn":{"type":"string","format":"date","description":"행사 시작일 (KST 날짜)","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일 (KST 날짜). 오늘 이전이면 13433","example":"2026-11-07"},"operatingHours":{"type":"string","description":"운영 시간 — POPUP 전용 필수","example":"11:00 ~ 20:00","maxLength":100,"minLength":0},"programDescription":{"type":"string","description":"주요 프로그램 — FESTIVAL 전용 필수","example":"멀티불꽃쇼, 뮤직 불꽃쇼, 드론 라이트쇼 운영","maxLength":2000,"minLength":10},"participationMethod":{"type":"string","description":"참여 방식 — EVENT 전용 필수. 부모 이벤트는 재제출로 바꿀 수 없어 이 요청에 필드가 없다","example":"부스 방문 후 현장에서 인증 영상을 촬영해 업로드하면 참여가 완료됩니다","maxLength":2000,"minLength":10},"description":{"type":"string","description":"행사 소개","example":"광안리해수욕장 일원에서 열리는 부산 대표 불꽃 축제","maxLength":2000,"minLength":10},"imageS3Key":{"type":"string","description":"대표 이미지의 pending S3 키. 생략하거나 null 이면 기존 이미지를 유지한다.","example":"event-submissions/pending/12/3f0c1f2e-....jpg"},"locations":{"type":"array","description":"행사 위치 목록. 통째로 갈아끼우고 대표 격자를 전부 재계산한다.","items":{"$ref":"#/components/schemas/EventSubmissionLocationRequestDto"}}},"required":["description","endsOn","organizerName","startsOn","title"]},"NotificationPreferenceUpdateRequestDto":{"type":"object","description":"카테고리 수신 토글 요청 — 같은 값 재전환은 멱등하게 성공한다","properties":{"enabled":{"type":"boolean","description":"수신 여부 — false 면 off(거부 행 저장), true 면 on(행 삭제)","example":false}},"required":["enabled"]},"ApiResponseDtoNotificationPreferenceResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationPreferenceResponseDto"}},"required":["data","developCode","message"]},"CategoryPreferenceDto":{"type":"object","description":"카테고리 하나의 수신 상태","properties":{"category":{"type":"string","description":"알림 카테고리","enum":["BADGE","HOTZONE","REMIND","VIDEO","WEEKLY","FRIEND","MISSION_NEARBY","EVENT"],"example":"HOTZONE"},"enabled":{"type":"boolean","description":"수신 여부 — off 행 부재면 true (opt-out 기본 전부 on)","example":true}},"required":["category","enabled"]},"NotificationPreferenceResponseDto":{"type":"object","description":"알림 설정 — 전 카테고리(8종)의 수신 상태 (저장 행 없는 카테고리는 true)","properties":{"preferences":{"type":"array","description":"카테고리별 수신 상태 (BADGE·HOTZONE·REMIND·VIDEO·WEEKLY·FRIEND·MISSION_NEARBY·EVENT 고정 8종 — MODERATION 은 설정 대상이 아니라 없다)","items":{"$ref":"#/components/schemas/CategoryPreferenceDto"}}},"required":["preferences"]},"ApiResponseDtoListZoneResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ZoneResponseDto"}}},"required":["data","developCode","message"]},"ZoneResponseDto":{"type":"object","description":"구역(zone)의 이름과 격자 사각형 범위 — 검색바 구역 이동·범위 오버레이용. 격자 표시명은 서버가 계산해 격자 응답에 함께 싣는다.","properties":{"zoneKey":{"type":"string","description":"안정 식별자 slug (zones.zone_key) — 클라이언트 참조·타이브레이크 기준","example":"seomyeon"},"name":{"type":"string","description":"구역명 (zones.name)","example":"서면"},"regionCode":{"type":["string","null"],"description":"소속 행정동 코드 (zones.region_code, nullable)","example":"2623051000"},"minGridY":{"type":"integer","format":"int32","description":"사각형 남단 행 (zones.min_grid_y)","example":16850},"maxGridY":{"type":"integer","format":"int32","description":"사각형 북단 행 = A행 (zones.max_grid_y)","example":16866},"minGridX":{"type":"integer","format":"int32","description":"사각형 서단 열 = 1열 (zones.min_grid_x)","example":11414},"maxGridX":{"type":"integer","format":"int32","description":"사각형 동단 열 (zones.max_grid_x)","example":11424},"priority":{"type":"integer","format":"int32","description":"겹침 결정성 우선순위 (zones.priority)","example":0}},"required":["maxGridX","maxGridY","minGridX","minGridY","name","priority","regionCode","zoneKey"]},"ApiResponseDtoVideoPlaybackResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/VideoPlaybackResponseDto"}},"required":["data","developCode","message"]},"VideoPlaybackResponseDto":{"type":"object","description":"단건 영상 재생 조회 응답","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID","example":1042},"playbackUrl":{"type":["string","null"],"description":"재생본 presigned GET URL. READY 아님·BLINDED(소유자)면 null"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. 썸네일 key 없음(READY 이전)이면 null"},"gridId":{"type":"string","description":"이 영상이 속한 격자 ID","example":"19422_9582"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"visibility":{"type":"string","description":"공개 범위 (PUBLIC, PRIVATE, FRIENDS 중 하나)","example":"PUBLIC"},"status":{"type":"string","description":"영상 상태 (ACTIVE/BLINDED). 소유자가 블라인드 사유를 구분하는 축","example":"ACTIVE"},"viewCount":{"type":"integer","format":"int64","description":"조회수 (이번 조회 증가 전 스냅샷)","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용)","example":"2026-07-20T18:03:11Z"},"expiresInSec":{"type":["integer","null"],"format":"int64","description":"playbackUrl presign TTL(초). playbackUrl=null 이면 null"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속(해상 등)이거나 미판정이면 null","example":"서울특별시 강남구 역삼1동"},"highlights":{"type":["array","null"],"description":"AI 추천 하이라이트 구간 [[시작초, 끝초], ...]. 최대 3구간, 초는 소수점 둘째 자리. 배열 순서가 추천 우선순위(첫 요소가 최우선 추천). 없으면 null (READY 이전·FAILED·0구간 포함, 빈 배열은 내려가지 않는다) 예시: [[0.0, 4.25], [12.0, 18.5], [20.0, 27.5]]","items":{"type":"array","items":{"type":"number","format":"double"}}},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"},"userId":{"type":"integer","format":"int64","description":"작성자 사용자 ID (videos.user_id). 차단(POST /api/users/{userId}/block)의 경로 값","example":42}},"required":["durationSec","expiresInSec","gridId","highlights","nickname","playbackUrl","processingStatus","recordedAt","regionName","status","thumbnailUrl","userId","videoId","viewCount","visibility","zoneCell","zoneName"]},"ApiResponseDtoListBlockedUserResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/BlockedUserResponseDto"}}},"required":["data","developCode","message"]},"BlockedUserResponseDto":{"type":"object","description":"내가 차단한 사용자 목록 항목","properties":{"userId":{"type":"integer","format":"int64","description":"차단한 사용자 ID. 해제(DELETE /api/users/{userId}/block)의 경로 값","example":42},"nickname":{"type":"string","description":"닉네임 원문(조회 시점 값, 사본 아님)","example":"busan.vlog"},"profileImageUrl":{"type":["string","null"],"description":"프로필 이미지 URL. 없으면 null"},"blockedAt":{"type":"string","format":"date-time","description":"차단 시각(최초 차단 시각, 재차단해도 바뀌지 않는다)","example":"2026-09-08T03:10:00Z"}},"required":["blockedAt","nickname","profileImageUrl","userId"]},"ApiResponseDtoListTrendingKeywordResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/TrendingKeywordResponseDto"}}},"required":["data","developCode","message"]},"TrendingKeywordResponseDto":{"type":"object","description":"인기 검색어 1건. 클릭 시 keyword 로 기존 장소 검색 API 를 다시 호출한다.","properties":{"rank":{"type":"integer","format":"int32","description":"순위 (1부터)","example":1},"keyword":{"type":"string","description":"정규화된 검색어","example":"홍대 카페"}},"required":["keyword","rank"]},"ApiResponseDtoListPlaceSearchResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/PlaceSearchResponseDto"}}},"required":["data","developCode","message"]},"PlaceSearchResponseDto":{"type":"object","description":"장소 검색 결과 1건. 선택 시 lat/lng 로 지도 이동 + gridId 로 격자 하이라이트를 한 번에 처리한다.","properties":{"name":{"type":"string","description":"장소명 (카카오 place_name)","example":"부산대학교"},"address":{"type":"string","description":"표시용 주소 — 도로명 우선, 없으면 지번 (§D2)","example":"부산 금정구 부산대학로63번길 2"},"lat":{"type":"number","format":"double","description":"위도 (WGS84, 카카오 y 직결 — 변환 없음)","example":35.23272},"lng":{"type":"number","format":"double","description":"경도 (WGS84, 카카오 x)","example":129.08246},"gridId":{"type":"string","description":"그 좌표의 격자 ID — FE 격자 하이라이트 키 (즉석 계산, 저장 아님)","example":"16941_11439"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖이면 null — 표시 라벨은 address 가 맡으므로 행정동 폴백 재료를 싣지 않는다(§D2 유지).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null 이다.","example":"I-6"}},"required":["address","gridId","lat","lng","name","zoneCell","zoneName"]},"ApiResponseDtoRegionExploreResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionExploreResponseDto"}},"required":["data","developCode","message"]},"ExploreGridResponseDto":{"type":"object","description":"전역 탐색 격자 카드","properties":{"gridId":{"type":"string","description":"격자 ID — 카드 탭 시 격자 전역 영상 목록(MSG-237) 진입 키","example":"16676_11596"},"gridY":{"type":"integer","format":"int64","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다). FE 지도 이동·라벨 조합","example":16676},"gridX":{"type":"integer","format":"int64","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":11596},"videoCount":{"type":"integer","format":"int32","description":"그 격자의 게이트 통과 영상 수 — \"N개 영상\"","example":138},"coverThumbnailUrl":{"type":["string","null"],"description":"커버 썸네일 presigned GET URL. READY 게이트라 non-null 기대(null 이면 null 통과)"},"coverDurationSec":{"type":"integer","format":"int32","description":"커버 영상 길이(초) — duration 뱃지","example":12},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 FE 는 래퍼의 regionName 을 라벨로 쓴다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["coverDurationSec","coverThumbnailUrl","gridId","gridX","gridY","videoCount","zoneCell","zoneName"]},"RegionExploreResponseDto":{"type":"object","description":"행정동 격자 카드 리스트 + 헤더 카운트","properties":{"regionCode":{"type":"string","description":"행정동 코드 (요청 에코)","example":"2644056000"},"regionName":{"type":["string","null"],"description":"행정동 이름 — 미존재 코드면 null","example":"부산광역시 부산진구 부전2동"},"gridCount":{"type":"integer","format":"int32","description":"게이트 통과 영상 ≥1 격자 수 — \"이 지역 격자 N개\"","example":5},"videoCount":{"type":"integer","format":"int64","description":"게이트 통과 영상 총수 — \"영상 M개\"","example":355},"grids":{"type":"array","description":"격자 카드 (정렬·limit 적용 후). 없으면 빈 배열","items":{"$ref":"#/components/schemas/ExploreGridResponseDto"}}},"required":["gridCount","grids","regionCode","regionName","videoCount"]},"ApiResponseDtoListRegionStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionStatResponseDto"}}},"required":["data","developCode","message"]},"RegionStatResponseDto":{"type":"object","description":"한 행정동의 수집률. 사용자가 그 행정동에서 점령(수집)한 격자 수와 진행률.","properties":{"regionCode":{"type":"string","description":"행정동 코드 (region_stats.region_code)","example":"1168051500"},"regionName":{"type":"string","description":"행정동 이름 (regions.region_name)","example":"서울특별시 강남구 역삼1동"},"parentCode":{"type":["string","null"],"description":"상위 시군구 코드 (regions.parent_code) — NULL 허용 컬럼이라 최상위 행은 null","example":"11680"},"collectedCount":{"type":"integer","format":"int32","description":"점령(수집)한 격자 수","example":5},"totalCount":{"type":"integer","format":"int32","description":"그 행정동 전체 격자 수(분모)","example":20},"progressRate":{"type":"number","description":"수집률(%) — 100 상한 clamp","example":25.0},"updatedAt":{"type":"string","format":"date-time","description":"수집률 캐시 기준 시각","example":"2026-07-20T10:00:00Z"}},"required":["collectedCount","parentCode","progressRate","regionCode","regionName","totalCount","updatedAt"]},"ApiResponseDtoRegionNationalStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionNationalStatResponseDto"}},"required":["data","developCode","message"]},"RegionNationalStatResponseDto":{"type":"object","description":"내 전국 탐험률 재료. 점령한 격자 수(분자)와 전국 격자 총수(분모)의 원값.","properties":{"collectedCount":{"type":"integer","format":"int64","description":"내가 점령(수집)한 격자 수의 전국 합. 수집이 없으면 0","example":1223},"totalCount":{"type":"integer","format":"int64","description":"전국 격자 총수(분모). 0 이면 기준 데이터 미적재 상태라 화면은 비율을 그리지 않는다","example":10193482}},"required":["collectedCount","totalCount"]},"ApiResponseDtoRegionStatResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/RegionStatResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"ApiResponseDtoRegionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/RegionResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"RegionResponseDto":{"type":"object","description":"좌표를 포함하는 행정동. 포함 행정동이 없으면(바다·국외) data 가 null 이다.","properties":{"regionCode":{"type":"string","description":"행정동 코드 (regions.region_code = adm_cd2)","example":"1168051500"},"regionName":{"type":"string","description":"행정동 이름 (regions.region_name = adm_nm)","example":"서울특별시 강남구 역삼1동"},"parentCode":{"type":["string","null"],"description":"상위 시군구 코드 (regions.parent_code) — NULL 허용 컬럼이라 최상위 행은 null","example":"11680"}},"required":["parentCode","regionCode","regionName"]},"ApiResponseDtoRegionExplorePageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RegionExplorePageResponseDto"}},"required":["data","developCode","message"]},"RegionExplorePageResponseDto":{"type":"object","description":"전체 지역 개인화 커서 페이지","properties":{"items":{"type":"array","description":"현재 페이지 행정동 목록. 최대 20개","items":{"$ref":"#/components/schemas/RegionGridCountResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부"},"nextCursor":{"type":["string","null"],"description":"다음 요청에 그대로 전달할 불투명 커서"}},"required":["hasNext","items","nextCursor"]},"RegionGridCountResponseDto":{"type":"object","description":"전체 지역 리스트 항목 (행정동별 격자 수)","properties":{"regionCode":{"type":"string","description":"행정동 코드 — 선택 시 격자 카드 조회에 전달","example":"2644056000"},"regionName":{"type":"string","description":"행정동 이름","example":"부산광역시 부산진구 부전2동"},"gridCount":{"type":"integer","format":"int32","description":"그 행정동의 게이트 통과 격자 수","example":5}},"required":["gridCount","regionCode","regionName"]},"ApiResponseDtoListRegionDistrictResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionDistrictResponseDto"}}},"required":["data","developCode","message"]},"RegionDistrictResponseDto":{"type":"object","description":"시군구 한 건. 이름·식별자·전체 격자 수.","properties":{"parentCode":{"type":"string","description":"시군구 식별자(행정동 코드 앞 5자리). /api/regions/stats 의 parentCode 로 그대로 쓴다","example":"11680"},"name":{"type":"string","description":"시군구 이름","example":"강남구"},"gridCount":{"type":"integer","format":"int64","description":"그 시군구의 전체 격자 수(사용자 무관). 0 인 시군구는 목록에 없다","example":4102}},"required":["gridCount","name","parentCode"]},"ApiResponseDtoOrgEventListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OrgEventListResponseDto"}},"required":["data","developCode","message"]},"OrgEventCityCountResponseDto":{"type":"object","description":"시·도별 승인 이벤트 건수 — 모달 시·도 칩 재료","properties":{"cityName":{"type":"string","description":"시·도 이름 — city 필터에 그대로 넣는 값","example":"부산"},"count":{"type":"integer","format":"int32","description":"그 시·도의 승인 이벤트 수 (전체 기준)","example":3}},"required":["cityName","count"]},"OrgEventItemResponseDto":{"type":"object","description":"승인 이벤트 하나 — 참여 신청(MSG-502)이 부모로 지정할 후보","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id — 참여 신청의 부모 참조값","example":1},"name":{"type":"string","description":"이벤트 이름 (회차 제목)","example":"부산국제영화제"},"cityName":{"type":"string","description":"대상 지역 시·도 — 시·도 칩 묶음 기준","example":"부산"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2026-10-06T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2026-10-15T13:00:00Z"},"placeLabel":{"type":["string","null"],"description":"장소 라벨 — 표시 순서가 가장 앞선 위치의 이름. 위치가 없는 회차면 null","example":"영화의전당"}},"required":["cityName","endsAt","name","occurrenceId","placeLabel","startsAt"]},"OrgEventListResponseDto":{"type":"object","description":"승인 이벤트 목록 — 참여 신청 모달 재료","properties":{"totalCount":{"type":"integer","format":"int32","description":"승인 이벤트 전체 건수 — 필터·검색과 무관한 '전체 보기' 칩 재료","example":4},"cityCounts":{"type":"array","description":"시·도별 건수 — 건수 내림차순, 동수는 이름 오름차순","items":{"$ref":"#/components/schemas/OrgEventCityCountResponseDto"}},"events":{"type":"array","description":"필터·검색이 적용된 목록 — 시작일 오름차순, 동시각은 회차 id 오름차순","items":{"$ref":"#/components/schemas/OrgEventItemResponseDto"}}},"required":["cityCounts","events","totalCount"]},"ApiResponseDtoEventSubmissionDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventSubmissionDetailResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionDetailResponseDto":{"type":"object","description":"신청 상세","properties":{"id":{"type":"integer","format":"int64","description":"신청 id","example":7},"submissionNo":{"type":"string","description":"신청 번호","example":"FM-2026-0007"},"type":{"type":"string","description":"등록 유형","example":"FESTIVAL"},"status":{"type":"string","description":"신청 상태","example":"REJECTED"},"title":{"type":"string","description":"축제명 / 팝업명"},"organizerName":{"type":"string","description":"주최 기관 / 브랜드·운영사"},"startsOn":{"type":"string","format":"date","description":"행사 시작일","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일","example":"2026-11-07"},"operatingHours":{"type":["string","null"],"description":"운영 시간 — POPUP 만 값이 있다"},"programDescription":{"type":["string","null"],"description":"주요 프로그램 — FESTIVAL 만 값이 있다"},"participationMethod":{"type":["string","null"],"description":"참여 방식 — EVENT 만 값이 있다"},"parentEvent":{"anyOf":[{"$ref":"#/components/schemas/EventSubmissionParentEventResponseDto"},{"type":"null"}],"description":"참여할 부모 이벤트 — EVENT 만 값이 있다"},"description":{"type":"string","description":"행사 소개"},"imageUrl":{"type":"string","description":"대표 이미지 열람용 presigned GET URL"},"locations":{"type":"array","description":"위치 목록 — 순번 오름차순","items":{"$ref":"#/components/schemas/EventSubmissionLocationResponseDto"}},"rejection":{"anyOf":[{"$ref":"#/components/schemas/EventSubmissionRejectionResponseDto"},{"type":"null"}],"description":"현재 반려 사유 — 상태가 REJECTED 일 때만 값이 있다"},"history":{"type":"array","description":"상태 이력 — 발생 순","items":{"$ref":"#/components/schemas/EventSubmissionHistoryResponseDto"}},"updatedAt":{"type":"string","format":"date-time","description":"마지막 변경 시각 (UTC)","example":"2026-08-28T02:11:00Z"}},"required":["description","endsOn","history","id","imageUrl","locations","operatingHours","organizerName","parentEvent","participationMethod","programDescription","rejection","startsOn","status","submissionNo","title","type","updatedAt"]},"EventSubmissionHistoryResponseDto":{"type":"object","description":"신청 상태 이력 항목","properties":{"status":{"type":"string","description":"전이 후 상태","example":"REJECTED"},"reasonCodes":{"type":["array","null"],"description":"반려 항목 코드 — 반려 행에만 있고 그 외에는 null","items":{"type":"string"}},"reasonText":{"type":["string","null"],"description":"반려 사유 본문 — 반려 행에만 있고 그 외에는 null"},"changedAt":{"type":"string","format":"date-time","description":"전이 시각 (UTC)","example":"2026-08-28T02:00:00Z"}},"required":["changedAt","reasonCodes","reasonText","status"]},"EventSubmissionLocationResponseDto":{"type":"object","description":"신청 위치 상세","properties":{"order":{"type":"integer","format":"int32","description":"위치 순번 — 제출 배열 순서대로 1부터","example":1},"representativeGridId":{"type":"string","description":"서버가 계산한 대표 격자 id","example":"16860_11512"},"zoneName":{"type":["string","null"],"description":"구역 표시명 — 구역 밖이면 null","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 안 칸 이름 — 구역 밖이면 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"행정동 이름 — 무귀속이면 null","example":"부산 수영구 광안동"},"cellCount":{"type":"integer","format":"int32","description":"영역 합집합 칸 수 — 최대 81","example":21},"areaRects":{"type":"array","description":"제출 원본 사각형 — 재제출 폼 프리필 재료라 보낸 그대로다","items":{"$ref":"#/components/schemas/EventSubmissionAreaRectDto"}}},"required":["areaRects","cellCount","order","regionName","representativeGridId","zoneCell","zoneName"]},"EventSubmissionParentEventResponseDto":{"type":"object","description":"참여할 부모 이벤트 회차","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"회차 id","example":1},"name":{"type":"string","description":"이벤트 이름","example":"부산국제영화제"}},"required":["name","occurrenceId"]},"EventSubmissionRejectionResponseDto":{"type":"object","description":"반려 항목과 사유","properties":{"reasonCodes":{"type":"array","description":"반려 항목 코드 — PERIOD, AREA, IMAGE, INFO","example":["AREA","INFO"],"items":{"type":"string"}},"reasonText":{"type":"string","description":"반려 사유 본문"}},"required":["reasonCodes","reasonText"]},"ApiResponseDtoEventSubmissionMyListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventSubmissionMyListResponseDto"}},"required":["data","developCode","message"]},"EventSubmissionMyListResponseDto":{"type":"object","description":"내 신청 목록과 상태별 건수","properties":{"counts":{"$ref":"#/components/schemas/EventSubmissionStatusCountsResponseDto","description":"상태별 건수 — 내 신청 전체 기준"},"submissions":{"type":"array","description":"신청 목록 — 최신 제출 순","items":{"$ref":"#/components/schemas/EventSubmissionSummaryResponseDto"}}},"required":["counts","submissions"]},"EventSubmissionStatusCountsResponseDto":{"type":"object","description":"내 신청의 상태별 건수","properties":{"inReview":{"type":"integer","format":"int64","description":"심사 중 건수","example":2},"approved":{"type":"integer","format":"int64","description":"승인 건수","example":1},"rejected":{"type":"integer","format":"int64","description":"반려 건수","example":1}},"required":["approved","inReview","rejected"]},"EventSubmissionSummaryResponseDto":{"type":"object","description":"내 신청 목록 항목","properties":{"id":{"type":"integer","format":"int64","description":"신청 id","example":7},"submissionNo":{"type":"string","description":"신청 번호","example":"FM-2026-0007"},"type":{"type":"string","description":"등록 유형","example":"FESTIVAL"},"title":{"type":"string","description":"축제명 / 팝업명","example":"부산불꽃축제"},"status":{"type":"string","description":"신청 상태","example":"REJECTED"},"startsOn":{"type":"string","format":"date","description":"행사 시작일","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일","example":"2026-11-07"},"updatedAt":{"type":"string","format":"date-time","description":"마지막 변경 시각 (UTC)","example":"2026-08-28T02:11:00Z"}},"required":["endsOn","id","startsOn","status","submissionNo","title","type","updatedAt"]},"ApiResponseDtoNotificationPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationPageResponseDto"}},"required":["data","developCode","message"]},"NotificationItemResponseDto":{"type":"object","description":"알림 한 건","properties":{"notificationId":{"type":"integer","format":"int64","description":"알림 ID — 읽음 처리와 커서에 쓴다","example":123},"category":{"type":"string","description":"알림 카테고리","enum":["BADGE","HOTZONE","REMIND","VIDEO","WEEKLY","FRIEND","MODERATION","EVENT"],"example":"BADGE"},"title":{"type":"string","description":"알림 제목","example":"새 뱃지 획득"},"body":{"type":"string","description":"알림 본문","example":"'첫 걸음' 뱃지를 획득했어요"},"createdAt":{"type":"string","format":"date-time","description":"생성 시각 (UTC)","example":"2026-08-19T02:11:00Z"},"read":{"type":"boolean","description":"읽음 여부","example":false}},"required":["body","category","createdAt","notificationId","read","title"]},"NotificationPageResponseDto":{"type":"object","description":"알림함 목록 한 페이지 — 최신순(id 내림차순)","properties":{"notifications":{"type":"array","description":"알림 항목 — 없으면 빈 배열","items":{"$ref":"#/components/schemas/NotificationItemResponseDto"}},"nextCursor":{"type":["integer","null"],"format":"int64","description":"다음 페이지 커서 — 다음 요청의 cursor 로 그대로 되돌려 준다. hasNext 가 false 면 null","example":123},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부","example":true}},"required":["hasNext","nextCursor","notifications"]},"ApiResponseDtoNotificationUnreadCountResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/NotificationUnreadCountResponseDto"}},"required":["data","developCode","message"]},"NotificationUnreadCountResponseDto":{"type":"object","description":"안읽은 알림 개수 — 목록과 같은 노출 조건(최근 30일·수신 거부 스킵 제외)","properties":{"count":{"type":"integer","format":"int64","description":"안읽은 알림 개수 — 없으면 0","example":3}},"required":["count"]},"ApiResponseDtoMissionDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/MissionDetailResponseDto"}},"required":["data","developCode","message"]},"BoxShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"polygon":{"type":"array","items":{"$ref":"#/components/schemas/LatLng"}}}}],"description":"축제·팝업(EVENT·POPUP) — 격자 집합을 감싸는 경계 사각형","required":["polygon"]},"Cell":{"type":"object","description":"격자 중심점","properties":{"gridId":{"type":"string"},"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"}},"required":["gridId","lat","lng"]},"CellsShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"cells":{"type":"array","items":{"$ref":"#/components/schemas/Cell"}}}}],"description":"테마·지속(THEME·CONTINUOUS) — 각 격자 중심점","required":["cells"]},"LatLng":{"type":"object","description":"좌표 한 점","properties":{"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"}},"required":["lat","lng"]},"MissionDetailResponseDto":{"type":"object","description":"미션 상세 — 미션 정보 + 내 진행도 + 전체 영상 개수 + 코스 스팟별 통계","properties":{"mission":{"$ref":"#/components/schemas/MissionResponseDto","description":"미션 정보 — 목록(GET /api/missions/active)과 같은 필드·shape"},"progress":{"anyOf":[{"$ref":"#/components/schemas/MissionProgressResponseDto"},{"type":"null"}],"description":"내 진행도 — 목록 진행도(GET /api/missions/progress)와 같은 계산 (MSG-398 D8). 비로그인 조회면 키는 그대로 있고 값이 null 이다 (MSG-454)"},"videoCount":{"type":"integer","format":"int64","description":"미션 기간 안에 촬영된 전역 공개(ACTIVE·PUBLIC·READY) 영상 수 — 미션 영상 목록(MSG-390)의 실제 후보 수와 같다","example":19},"spotStats":{"type":"array","description":"코스 포토스팟별 방문 여부·영상 개수 — shape.spots 와 같은 순서(seq ASC NULLS LAST, gridId ASC). 코스가 아니면 빈 배열","items":{"$ref":"#/components/schemas/SpotStats"}}},"required":["mission","progress","spotStats","videoCount"]},"MissionProgressResponseDto":{"type":"object","description":"미션 하나에 대한 내 진행도","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 id (missions.id)","example":412},"targetCount":{"type":"integer","format":"int32","description":"완료에 필요한 격자 수 (missions.target_count)","example":1},"filledCount":{"type":"integer","format":"int32","description":"그 미션 격자 중 기간 안에 촬영한 내 영상이 있는 칸 수. targetCount 를 넘지 않는다","example":1},"completed":{"type":"boolean","description":"내 스탬프 보유 여부 (user_missions)","example":true}},"required":["completed","filledCount","missionId","targetCount"]},"MissionResponseDto":{"type":"object","description":"미션 하나 — 공통 필드 + 유형별 렌더 shape","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 id (missions.id)","example":12},"type":{"type":"string","description":"미션 유형 — FE 렌더러 판별자","enum":["COURSE","AREA","EVENT","THEME","CONTINUOUS","POPUP"],"example":"COURSE"},"title":{"type":"string","description":"미션 제목","example":"남파랑길 3코스"},"targetCount":{"type":"integer","format":"int32","description":"완료에 필요한 distinct 방문 격자 수(표시·판정 힌트, 판정은 MSG-223)","example":3},"startAt":{"type":["string","null"],"format":"date-time","description":"시작 시각. NULL = 무기간(상시)","example":"2026-11-01T00:00:00Z"},"endAt":{"type":["string","null"],"format":"date-time","description":"종료 시각. NULL = 무기간(상시)","example":"2026-11-01T23:59:59Z"},"shape":{"description":"유형별 렌더 shape 하나(type 에 대응하는 PATH/BOX/CELLS/REGION)","oneOf":[{"$ref":"#/components/schemas/BoxShape"},{"$ref":"#/components/schemas/CellsShape"},{"$ref":"#/components/schemas/PathShape"},{"$ref":"#/components/schemas/RegionShape"}]},"description":{"type":["string","null"],"description":"소개문 원문. 출처 표기 없이 그대로 노출한다","example":"부산 앞바다를 따라 걷는 해안 산책로"},"placeName":{"type":["string","null"],"description":"사람이 읽는 위치 한 줄 — 축제는 행사장, 팝업은 주소, 코스는 시군","example":"부산 영도구"},"sourceUrl":{"type":["string","null"],"description":"원문 링크 — 축제 홈페이지·팝업 상세 페이지. 코스는 없다","example":"https://festival.example.kr"},"operationTime":{"type":["string","null"],"description":"운영시간 안내 문구. 여러 줄이면 개행으로 이어 붙인다(팝업 전용)","example":"매일 11:00 ~ 20:00"},"imageUrl":{"type":["string","null"],"description":"대표 이미지 주소 — 우리 스토리지 URL 만 들어간다(MSG-383 §D7)","example":"https://cdn.fillmap.kr/mission/12.webp"},"distanceMeters":{"type":["integer","null"],"format":"int32","description":"코스 총 거리(미터). 코스가 아니면 없다","example":14000},"durationMinutes":{"type":["integer","null"],"format":"int32","description":"코스 소요시간(분). 코스가 아니면 없다","example":330},"difficulty":{"type":["integer","null"],"format":"int32","description":"코스 난이도 — 두루누비 등급 1(쉬움)·2(보통)·3(어려움). 코스가 아니면 없다","example":2}},"required":["description","difficulty","distanceMeters","durationMinutes","endAt","imageUrl","missionId","operationTime","placeName","shape","sourceUrl","startAt","targetCount","title","type"]},"MissionShape":{"description":"미션 유형별 렌더 shape (상위 type 으로 판별). PATH·BOX·CELLS·REGION 중 하나."},"PathShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"line":{"type":["object","null"],"description":"코스 라인 GeoJSON LineString 원문 — missions.path 는 NULL 허용 컬럼이라 없을 수 있다"},"spots":{"type":"array","items":{"$ref":"#/components/schemas/Spot"}}}}],"description":"코스(COURSE) — GeoJSON LineString + seq순 포토스팟 마커","required":["line","spots"]},"RegionShape":{"allOf":[{"$ref":"#/components/schemas/MissionShape"},{"type":"object","properties":{"regionCode":{"type":["string","null"],"description":"행정동 코드 — missions.region_code 는 NULL 허용 컬럼이라 없을 수 있다"}}}],"description":"구역(AREA) — region_code 만(경계는 region API 로 별도 조회)","required":["regionCode"]},"Spot":{"type":"object","description":"코스 포토스팟 마커","properties":{"gridId":{"type":"string"},"lat":{"type":"number","format":"double"},"lng":{"type":"number","format":"double"},"seq":{"type":["integer","null"],"format":"int32","description":"코스 내 순번 — mission_grids.seq 는 NULL 허용 컬럼이라 없을 수 있다"},"name":{"type":["string","null"],"description":"표시 이름 (MSG-492) — 명소 이름·구역 표시명(\"서면 A-14\")·행정동 이름 중 하나로 이미 조립된 문자열이다. 시더가 적재 시점에 정해 저장한 값을 그대로 통과시킨다. 코스가 아닌 유형의 스팟과 시더 갱신 전 스팟만 null — 화면은 기존 안내 문구를 폴백으로 남긴다"}},"required":["gridId","lat","lng","name","seq"]},"SpotStats":{"type":"object","description":"코스 포토스팟 하나의 방문 여부·영상 개수","properties":{"gridId":{"type":"string","description":"포토스팟 격자 id — shape.spots 의 gridId 에 대응","example":"38677_114635"},"visited":{"type":"boolean","description":"미션 기간 안에 촬영한 내 영상이 있는지 — 진행도와 같은 술어. 비로그인 조회면 항상 false (MSG-454)","example":true},"videoCount":{"type":"integer","format":"int64","description":"이 스팟에 올라온 전역 공개 영상 수. 영상이 없으면 0","example":9}},"required":["gridId","videoCount","visited"]},"ApiResponseDtoGridVideoPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridVideoPageResponseDto"}},"required":["data","developCode","message"]},"GridGlobalVideoResponseDto":{"type":"object","description":"전역 공개 영상 목록 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID. 항목 탭 → 단건 재생(GET /api/videos/{videoId}) 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL. 목록은 READY 만 담겨 null 아님이 기대값이다"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"viewCount":{"type":"integer","format":"int64","description":"조회수","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-07-20T18:03:11Z"},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"},"userId":{"type":"integer","format":"int64","description":"작성자 사용자 ID (videos.user_id). 차단(POST /api/users/{userId}/block)의 경로 값","example":42}},"required":["durationSec","nickname","recordedAt","thumbnailUrl","userId","videoId","viewCount"]},"GridVideoPageResponseDto":{"type":"object","description":"전역 공개 영상 목록 페이지 응답 (keyset 커서 페이지네이션)","properties":{"videos":{"type":"array","description":"이 페이지의 전역 공개·READY 영상. 없으면 빈 배열","items":{"$ref":"#/components/schemas/GridGlobalVideoResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null."}},"required":["hasNext","nextCursor","videos"]},"ApiResponseDtoListMissionProgressResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionProgressResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoListMissionRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionRegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"MissionRegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 미션 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드(10자리)를 단위 길이로 자른 접두(동 10, 구 5, 시 2자리). 행정동이 판정되지 않은 묶음만 null","example":"26230"},"name":{"type":["string","null"],"description":"단위 표시 이름 (동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). 무귀속만 null","example":"부산진구"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 묶음에 속한 미션 귀속점의 평균이라 마커가 실제 데이터 위에 선다","example":35.1568},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.0592},"count":{"type":"integer","format":"int32","description":"그 단위 안의 미션 수","example":12},"missionIds":{"type":"array","description":"그 묶음에 속한 미션 id 오름차순 — 줌인 후 개별 조회 결과와 교집합으로 목록을 좁힌다(D5). 크기는 count 와 같다","items":{"type":"integer","format":"int64"}}},"required":["count","lat","lng","missionIds","name","regionCode"]},"ApiResponseDtoListMissionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MissionResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoHotZoneListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/HotZoneListResponseDto"}},"required":["data","developCode","message"]},"HotZoneListResponseDto":{"type":"object","description":"뷰포트 내 핫구역 목록 응답 (핫스코어 내림차순)","properties":{"hotZones":{"type":"array","description":"핫구역 목록 — 핫스코어 내림차순. 없으면 빈 배열","items":{"$ref":"#/components/schemas/HotZoneResponseDto"}}},"required":["hotZones"]},"HotZoneResponseDto":{"type":"object","description":"핫구역 한 칸 — 최근 48시간 방문(업로드) 신호가 상위인 격자","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":9582},"score":{"type":"integer","format":"int64","description":"핫스코어 — 최근 48시간(8버킷) 방문 신호 합산","example":12},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 마커 라벨은 같은 항목의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단) — 마커 배지용. zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 부산진구 부전1동"}},"required":["gridId","gridX","gridY","regionName","score","zoneCell","zoneName"]},"ApiResponseDtoListHotZoneRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/HotZoneRegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"HotZoneRegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 핫구역 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드(10자리)를 단위 길이로 자른 접두(동 10, 구 5, 시 2자리). 행정동이 판정되지 않은 묶음만 null","example":"26230"},"name":{"type":["string","null"],"description":"단위 표시 이름 (동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). 무귀속만 null","example":"부산진구"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 묶음에 속한 핫 격자 셀 중심의 평균이라 마커가 실제 데이터 위에 선다","example":35.1568},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.0592},"count":{"type":"integer","format":"int32","description":"그 단위 안의 핫 격자 수 — 핫스코어 합산이 아니다","example":12},"gridIds":{"type":"array","description":"그 묶음에 속한 핫 격자 id 오름차순 — 줌인 후 개별 조회 결과와 교집합으로 목록을 좁힌다(D4). 크기는 count 와 같다","items":{"type":"string"}}},"required":["count","gridIds","lat","lng","name","regionCode"]},"ApiResponseDtoOccupiedGridPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/OccupiedGridPageResponseDto"}},"required":["data","developCode","message"]},"OccupiedGridPageResponseDto":{"type":"object","description":"뷰포트 색칠 격자 페이지 응답 (커서 페이지네이션)","properties":{"grids":{"type":"array","description":"이 페이지의 색칠 격자 목록 ((grid_y, grid_x) 오름차순)","items":{"$ref":"#/components/schemas/OccupiedGridResponseDto"}},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 커서. 다음 요청 cursor 파라미터에 넣는다. 마지막 페이지면 null.","example":"MTk0MjJfOTU4Mg=="}},"required":["grids","nextCursor"]},"OccupiedGridResponseDto":{"type":"object","description":"뷰포트 색칠 격자 한 칸 — 지도 렌더링용 위치 정보","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 세로 인덱스 (EPSG:5179 평면 y / 100 — 위도가 아니다)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 가로 인덱스 (EPSG:5179 평면 x / 100 — 경도가 아니다)","example":9582},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 표시 이름은 같은 항목의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단) — 셀 배지용. zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 부산진구 부전1동"}},"required":["gridId","gridX","gridY","regionName","zoneCell","zoneName"]},"ApiResponseDtoGridCellResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridCellResponseDto"}},"required":["data","developCode","message"]},"GridCellResponseDto":{"type":"object","description":"단일 격자의 내 색칠(점령) 상태. 미점령이어도 404가 아니라 occupied=false로 응답한다.","properties":{"gridId":{"type":"string","description":"격자 ID (\"{grid_y}_{grid_x}\" 포맷)","example":"19422_9582"},"occupied":{"type":"boolean","description":"내가 이 격자를 점령(색칠)했는지 여부","example":true},"videoCount":{"type":"integer","format":"int32","description":"이 격자에 올린 내 영상 수 (미점령이면 0)","example":3},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름. 구역 밖 격자면 null — 이때 표시 이름은 같은 응답의 regionName(행정동)이다(추가 호출 없음).","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A는 구역 북단, 열 1은 서단). zoneName 과 항상 쌍이라 구역 밖 격자면 함께 null 이다.","example":"I-6"},"regionName":{"type":["string","null"],"description":"격자 중심점이 속한 행정동 전체 이름. 아직 아무도 영상을 올리지 않은 격자에도 실린다. 어느 행정동에도 속하지 않으면(해상 등) null. zoneName 이 null 이면 이 값이 표시 이름 폴백이다(폴백에는 칸 번호를 붙이지 않는다).","example":"부산광역시 영도구 영선1동"}},"required":["gridId","occupied","regionName","videoCount","zoneCell","zoneName"]},"ApiResponseDtoListGridVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridVideoResponseDto"}}},"required":["data","developCode","message"]},"GridVideoResponseDto":{"type":"object","description":"격자별 내 영상 리스트 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키","example":1042},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"}},"required":["createdAt","durationSec","processingStatus","thumbnailUrl","videoId"]},"ApiResponseDtoListGridMissionResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridMissionResponseDto"}}},"required":["data","developCode","message"]},"GridMissionResponseDto":{"type":"object","description":"격자가 대표 격자인 미션","properties":{"missionId":{"type":"integer","format":"int64","description":"미션 ID — 상세(GET /api/missions/{missionId})로 넘어가는 키","example":412},"type":{"type":"string","description":"미션 종류 — EVENT(지역축제) 또는 POPUP(팝업스토어)","example":"EVENT"},"title":{"type":"string","description":"미션 이름","example":"부산 불꽃축제"},"startAt":{"type":["string","null"],"format":"date-time","description":"시작 시각","example":"2026-10-01T00:00:00Z"},"endAt":{"type":["string","null"],"format":"date-time","description":"종료 시각","example":"2026-10-07T14:59:59Z"},"videoCount":{"type":"integer","format":"int64","description":"미션 기간 안에 촬영된 전역 공개 영상 수 — 미션 상세의 videoCount 와 같은 술어다","example":37}},"required":["endAt","missionId","startAt","title","type","videoCount"]},"ApiResponseDtoGridHourlyUploadResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridHourlyUploadResponseDto"}},"required":["data","developCode","message"]},"GridHourlyUploadResponseDto":{"type":"object","description":"격자 전역 시간대 분포 응답 (KST 24구간)","properties":{"gridId":{"type":"string","description":"격자 ID","example":"19422_9582"},"hours":{"type":"array","description":"KST 0시부터 23시까지 24개 구간. 업로드가 없는 구간은 count 0","items":{"$ref":"#/components/schemas/HourlyUploadCountResponseDto"}}},"required":["gridId","hours"]},"HourlyUploadCountResponseDto":{"type":"object","description":"시간대 구간 하나의 업로드 수","properties":{"hour":{"type":"integer","format":"int32","description":"KST 기준 시 (0~23)","example":18},"count":{"type":"integer","format":"int64","description":"그 시간대의 전역 공개 영상 수. 업로드가 없으면 0","example":3}},"required":["count","hour"]},"ApiResponseDtoListGridEventLocationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/GridEventLocationResponseDto"}}},"required":["data","developCode","message"]},"GridEventLocationResponseDto":{"type":"object","description":"격자 역조회 결과 하나 — 회차와 해석된 행사 위치","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"소속 행사 회차 id","example":12},"occurrenceTitle":{"type":"string","description":"행사명","example":"부산불꽃축제"},"occurrenceStatus":{"type":"string","description":"서버 시각 기준 파생 상태 — 상세와 같은 계산","enum":["UPCOMING","LIVE","UPLOAD_GRACE","ARCHIVED"],"example":"LIVE"},"locationId":{"type":"integer","format":"int64","description":"해석된 행사 위치 id — 피드 진입 키","example":31},"locationName":{"type":"string","description":"위치 이름","example":"부산역 팝업"},"representativeGridId":{"type":"string","description":"대표 격자 — 피드(MSG-440)가 영상을 붙일 격자","example":"19443_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 안 위치 코드. 구역 밖이면 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자의 행정동 이름 — 구역 밖 표시명 폴백. 무귀속이면 null","example":"부전동"}},"required":["locationId","locationName","occurrenceId","occurrenceStatus","occurrenceTitle","regionName","representativeGridId","zoneCell","zoneName"]},"ApiResponseDtoGridCoverVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"anyOf":[{"$ref":"#/components/schemas/GridCoverVideoResponseDto"},{"type":"null"}]}},"required":["data","developCode","message"]},"GridCoverVideoResponseDto":{"type":"object","description":"격자 전역 대표 영상","properties":{"videoId":{"type":"integer","format":"int64","description":"대표 영상 ID. 개별 재생 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL. 대표는 항상 READY 라 null 이 아니다"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"viewCount":{"type":"integer","format":"int64","description":"조회수 — 대표 선정 정렬 키","example":37},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용). 정렬 tie-break 키는 createdAt 이다","example":"2026-07-20T18:03:11Z"},"nickname":{"type":"string","description":"작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다","example":"busan.vlog"}},"required":["durationSec","nickname","recordedAt","thumbnailUrl","videoId","viewCount"]},"ApiResponseDtoGridAggregationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/GridAggregationResponseDto"}},"required":["data","developCode","message"]},"CurrentRegionResponseDto":{"type":"object","description":"뷰포트 중심이 속한 현재 행정동과 개인 점령 요약","properties":{"regionCode":{"type":"string","description":"행정동 코드 10자리","example":"2623058000"},"name":{"type":"string","description":"동 이름 한 토큰","example":"부전2동"},"gridCount":{"type":"integer","format":"int32","description":"이 행정동 전체에서 내가 점령한 격자 수(뷰포트 무관)","example":5},"videoCount":{"type":"integer","format":"int64","description":"이 행정동 전체에서 내 격자에 올린 영상 수(뷰포트 무관)","example":355}},"required":["gridCount","name","regionCode","videoCount"]},"GridAggregationResponseDto":{"type":"object","description":"뷰포트 점령 격자 묶음과 현재 동네 집계","properties":{"currentRegion":{"anyOf":[{"$ref":"#/components/schemas/CurrentRegionResponseDto"},{"type":"null"}],"description":"뷰포트 중심이 속한 행정동. 해상이나 서비스 범위 밖이면 null"},"items":{"type":"array","description":"뷰포트 안에서 행정 단위로 묶은 내 점령 격자 목록","items":{"$ref":"#/components/schemas/RegionAggregateResponseDto"}}},"required":["currentRegion","items"]},"RegionAggregateResponseDto":{"type":"object","description":"행정 단위로 묶어 센 점령 격자 집계 한 항목","properties":{"regionCode":{"type":["string","null"],"description":"묶음 키 — 행정동 코드를 단위 길이로 자른 접두(동 10자리, 구 5자리, 시 2자리). 행정동이 판정되지 않은 격자 묶음만 null 이다.","example":"2623058000"},"name":{"type":["string","null"],"description":"단위 표시 이름(동 \"부전2동\", 구 \"부산진구\", 시 \"부산광역시\"). \"부산광역시 214\" 를 \"부산 214\" 로 줄이는 표기 축약은 클라이언트 몫이다. 행정동이 판정되지 않은 격자 묶음만 null 이다.","example":"부전2동"},"lat":{"type":"number","format":"double","description":"마커 대표 좌표 위도 — 그 묶음에 속한 점령 격자 중심의 평균이다(행정 경계 무게중심이 아니다)","example":35.162},"lng":{"type":"number","format":"double","description":"마커 대표 좌표 경도","example":129.065},"count":{"type":"integer","format":"int32","description":"그 단위 안 점령 격자 수. 항목을 더 묶어 합산해도 같은 뷰포트 개별 조회 총수와 일치한다","example":31}},"required":["count","lat","lng","name","regionCode"]},"ApiResponseDtoListFriendListItemResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FriendListItemResponseDto"}}},"required":["data","developCode","message"]},"FriendListItemResponseDto":{"type":"object","description":"친구 목록 항목 — 수락된 친구 한 명.","properties":{"userId":{"type":"integer","format":"int64","description":"친구의 사용자 id — 프로필 조회·친구 삭제 경로 변수로 그대로 쓴다","example":7},"nickname":{"type":"string","description":"친구의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"친구의 프로필 이미지 URL — 미설정이면 null"},"gridColor":{"type":"string","description":"친구의 도감 색상 — 지도에서 친구가 수집한 격자를 칠하는 색","enum":["BLUE","GREEN","PURPLE","ORANGE","PINK","YELLOW","RED","TEAL"],"example":"PINK"}},"required":["gridColor","nickname","profileImageUrl","userId"]},"ApiResponseDtoFriendProfileResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendProfileResponseDto"}},"required":["data","developCode","message"]},"CollectionSummaryResponseDto":{"type":"object","description":"개인 도감 요약 — 점령한 격자 수·올린 영상 총합·방문한 행정동 수·현재/최장 스트릭·획득 뱃지 수.","properties":{"totalGridCount":{"type":"integer","format":"int32","description":"내가 점령한 격자 수 (도감 크기)","example":15},"totalVideoCount":{"type":"integer","format":"int64","description":"내가 올린 영상 총합 (활성 영상만)","example":42},"visitedRegionCount":{"type":"integer","format":"int32","description":"내가 방문한 서로 다른 행정동 수","example":6},"currentStreak":{"type":"integer","format":"int32","description":"현재 스트릭 (연속 업로드 일수). 마지막 기록이 KST 그제 이전이면 끊긴 것으로 보고 0","example":12},"maxStreak":{"type":"integer","format":"int32","description":"최장 스트릭. 끊겨도 유지되는 역대 최고 기록","example":21},"badgeCount":{"type":"integer","format":"int32","description":"획득한 뱃지 수","example":7}},"required":["badgeCount","currentStreak","maxStreak","totalGridCount","totalVideoCount","visitedRegionCount"]},"FriendCollectionGridResponseDto":{"type":"object","description":"친구가 수집한 격자 하나 — 썸네일은 재생 허용 영상이 있을 때만 붙는다.","properties":{"gridId":{"type":"string","description":"격자 ID \"{grid_y}_{grid_x}\"","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 Y 인덱스(지도 이동용, gridId 디코드값)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 X 인덱스(지도 이동용, gridId 디코드값)","example":9582},"firstCollectedAt":{"type":"string","format":"date-time","description":"친구가 이 격자를 처음 수집한 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"lastUploadedAt":{"type":"string","format":"date-time","description":"친구의 마지막 업로드 시각","example":"2026-07-21T09:12:00Z"},"videoCount":{"type":"integer","format":"int32","description":"그 격자에 친구가 올린 영상 수","example":3},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL — 재생 허용 영상이 없으면 null"},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름(무귀속/미판정이면 null)","example":"서울특별시 강남구 역삼1동"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["firstCollectedAt","gridId","gridX","gridY","lastUploadedAt","regionName","thumbnailUrl","videoCount","zoneCell","zoneName"]},"FriendProfileResponseDto":{"type":"object","description":"친구 프로필 — 프로필 정보와 도감 요약·최근 수집 격자.","properties":{"nickname":{"type":"string","description":"친구의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"친구의 프로필 이미지 URL — 미설정이면 null"},"gridColor":{"type":"string","description":"친구의 도감 색상","enum":["BLUE","GREEN","PURPLE","ORANGE","PINK","YELLOW","RED","TEAL"],"example":"PINK"},"summary":{"$ref":"#/components/schemas/CollectionSummaryResponseDto","description":"친구의 도감 요약 — 본인이 보는 값과 동일하다"},"recentGrids":{"type":"array","description":"친구가 최근 수집한 격자 최대 30개 — 수집 시각 역순","items":{"$ref":"#/components/schemas/FriendCollectionGridResponseDto"}}},"required":["gridColor","nickname","profileImageUrl","recentGrids","summary"]},"ApiResponseDtoListFriendGridVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/FriendGridVideoResponseDto"}}},"required":["data","developCode","message"]},"FriendGridVideoResponseDto":{"type":"object","description":"친구 격자 영상 리스트 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 재생 조회 진입 키","example":1042},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. 썸네일 key 가 없으면 null"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"}},"required":["createdAt","durationSec","thumbnailUrl","videoId"]},"ApiResponseDtoListRegionAggregateResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionAggregateResponseDto"}}},"required":["data","developCode","message"]},"ApiResponseDtoListReceivedFriendRequestResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ReceivedFriendRequestResponseDto"}}},"required":["data","developCode","message"]},"ReceivedFriendRequestResponseDto":{"type":"object","description":"받은 친구 요청 응답 — 최신 요청 우선 정렬.","properties":{"requesterId":{"type":"integer","format":"int64","description":"보낸 사용자 id — 수락/거절 호출의 경로 변수로 그대로 쓴다","example":3},"nickname":{"type":"string","description":"보낸 사용자의 닉네임","example":"채우미"},"profileImageUrl":{"type":["string","null"],"description":"보낸 사용자의 프로필 이미지 URL — 미설정이면 null"},"requestedAt":{"type":"string","format":"date-time","description":"요청 시각","example":"2026-08-03T12:00:00Z"}},"required":["nickname","profileImageUrl","requestedAt","requesterId"]},"ApiResponseDtoFriendPreviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendPreviewResponseDto"}},"required":["data","developCode","message"]},"FriendPreviewResponseDto":{"type":"object","description":"친구 코드 미리보기 응답 — 요청 확정 전 확인 화면(\"OOO님에게 요청을 보낼까요?\")용. 관계 상태(relation)를 함께 담아 화면이 요청 버튼의 활성 여부·문구를 미리 정할 수 있다 (MSG-391). 조회 전용이며 요청 API 가 전 검증을 재수행한다.","properties":{"nickname":{"type":"string","description":"코드 소유자의 닉네임 — SELF 면 내 닉네임","example":"채우미"},"relation":{"type":"string","description":"조회자와 코드 소유자의 관계 상태 — 조회 시점 실시간 판정 (MSG-391)","enum":["SELF","NONE","OUTGOING_PENDING","INCOMING_PENDING","FRIENDS"],"example":"NONE"}},"required":["nickname","relation"]},"ApiResponseDtoFriendCodeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/FriendCodeResponseDto"}},"required":["data","developCode","message"]},"FriendCodeResponseDto":{"type":"object","description":"내 친구 코드 응답","properties":{"friendCode":{"type":"string","description":"고정 친구 코드 — 혼동 문자(I·O·0·1) 제외 32종 8자, 재발급 없음","example":"AB3DE7GH"}},"required":["friendCode"]},"ApiResponseDtoEventVideoDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoDetailResponseDto"}},"required":["data","developCode","message"]},"EventVideoCommentPageResponseDto":{"type":"object","description":"행사 영상 댓글 페이지 (keyset 커서 페이지네이션)","properties":{"comments":{"type":"array","description":"이 페이지의 댓글 (오래된 순). 댓글이 없으면 빈 배열","items":{"$ref":"#/components/schemas/EventVideoCommentResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 그대로 넣는다. 마지막 페이지면 null"}},"required":["comments","hasNext","nextCursor"]},"EventVideoDetailResponseDto":{"type":"object","description":"행사 영상 상세","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID","example":1042},"occurrenceId":{"type":"integer","format":"int64","description":"소속 행사 회차 ID","example":12},"occurrenceStatus":{"type":"string","description":"요청 시점 회차 상태 (UPCOMING/LIVE/UPLOAD_GRACE/ARCHIVED)","example":"LIVE"},"locationId":{"type":"integer","format":"int64","description":"소속 행사 위치 ID","example":34},"locationName":{"type":"string","description":"소속 행사 위치 이름","example":"영화의전당"},"representativeGridId":{"type":"string","description":"영상이 붙은 대표 격자 ID","example":"19422_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\". zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자 중심점 행정동 이름 — 구역 밖 격자의 폴백 라벨. 무귀속이면 null","example":"부산광역시 부산진구 부전2동"},"playbackUrl":{"type":"string","description":"재생본 presigned GET URL"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초)","example":15},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각","example":"2026-10-06T12:00:00Z"},"createdAt":{"type":"string","format":"date-time","description":"업로드 시각","example":"2026-10-06T12:30:00Z"},"uploaderNickname":{"type":"string","description":"작성자 닉네임","example":"필맵러"},"interactionLocked":{"type":"boolean","description":"댓글·도움돼요 입력 UI 를 비활성화할지 여부 — 아카이브 전환(행사 종료 + 30일)부터 true","example":false},"helpfulCount":{"type":"integer","format":"int64","description":"도움돼요 수","example":12},"helpfulByMe":{"type":"boolean","description":"내가 도움돼요를 누른 상태인지. 비로그인 조회는 항상 false","example":false},"commentCount":{"type":"integer","format":"int64","description":"댓글 수","example":3},"comments":{"$ref":"#/components/schemas/EventVideoCommentPageResponseDto","description":"댓글 첫 페이지 (오래된 순 20건)"},"uploaderId":{"type":"integer","format":"int64","description":"작성자 사용자 ID (videos.user_id). 차단(POST /api/users/{userId}/block)의 경로 값","example":42}},"required":["commentCount","comments","createdAt","durationSec","helpfulByMe","helpfulCount","interactionLocked","locationId","locationName","occurrenceId","occurrenceStatus","playbackUrl","recordedAt","regionName","representativeGridId","uploaderId","uploaderNickname","videoId","zoneCell","zoneName"]},"ApiResponseDtoEventVideoCommentPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventVideoCommentPageResponseDto"}},"required":["data","developCode","message"]},"ApiResponseDtoListEventOccurrenceChipResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/EventOccurrenceChipResponseDto"}}},"required":["data","developCode","message"]},"EventOccurrenceChipResponseDto":{"type":"object","description":"뷰포트에 걸친 행사 회차 하나 — 지도 홈 칩 재료","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":12},"title":{"type":"string","description":"행사명 — 칩 라벨 재료","example":"부산불꽃축제"},"cityName":{"type":"string","description":"대상 지역 시 이름 — 시 칩 묶음 기준","example":"부산"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2026-10-06T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2026-10-15T13:00:00Z"},"status":{"type":"string","description":"서버 시각 기준 파생 상태 — 이 목록에는 세 값만 담긴다 (아카이브 회차는 빠진다)","enum":["UPCOMING","LIVE","UPLOAD_GRACE"],"example":"LIVE"}},"required":["cityName","endsAt","occurrenceId","startsAt","status","title"]},"ApiResponseDtoEventOccurrenceDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventOccurrenceDetailResponseDto"}},"required":["data","developCode","message"]},"EventOccurrenceDetailResponseDto":{"type":"object","description":"행사 회차 상세 — 이벤트 헤더","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":12},"seriesId":{"type":"integer","format":"int64","description":"행사 시리즈 id — 이전 회차 묶음 기준","example":3},"title":{"type":"string","description":"행사명","example":"부산불꽃축제"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2026-10-06T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2026-10-15T13:00:00Z"},"uploadClosesAt":{"type":"string","format":"date-time","description":"영상 업로드 마감 — 종료 30일 후 파생값","example":"2026-11-14T13:00:00Z"},"status":{"type":"string","description":"서버 시각 기준 파생 상태","enum":["UPCOMING","LIVE","UPLOAD_GRACE","ARCHIVED"],"example":"LIVE"},"notificationOn":{"type":"boolean","description":"알림 구독 여부 — 구독 행 존재이면서 회차가 예정·진행 중일 때만 true. 비로그인은 항상 false 고, 종료된 회차는 구독 행이 남아 있어도 false 다","example":false},"previousOccurrences":{"type":"array","description":"같은 시리즈의 지난 회차 — 최신순. 없으면 빈 배열","items":{"$ref":"#/components/schemas/PreviousOccurrenceDto"}},"imageUrl":{"type":["string","null"],"description":"대표 이미지 공개 URL — 이미지가 없는 회차는 null 이고 나머지 필드는 그대로다"}},"required":["endsAt","imageUrl","notificationOn","occurrenceId","previousOccurrences","seriesId","startsAt","status","title","uploadClosesAt"]},"PreviousOccurrenceDto":{"type":"object","description":"같은 시리즈의 지난 회차 하나","properties":{"occurrenceId":{"type":"integer","format":"int64","description":"행사 회차 id","example":9},"title":{"type":"string","description":"행사명","example":"부산불꽃축제"},"startsAt":{"type":"string","format":"date-time","description":"행사 시작 시각","example":"2025-10-04T01:00:00Z"},"endsAt":{"type":"string","format":"date-time","description":"행사 종료 시각","example":"2025-10-13T13:00:00Z"}},"required":["endsAt","occurrenceId","startsAt","title"]},"ApiResponseDtoEventViewerCountResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventViewerCountResponseDto"}},"required":["data","developCode","message"]},"EventViewerCountResponseDto":{"type":"object","description":"이벤트 현재 열람 인원 응답.","properties":{"viewerCount":{"type":["integer","null"],"format":"int32","description":"현재 열람 인원 — 마지막 heartbeat 가 90초 이내인 고유 세션 수. 0 은 아무도 없음(표시), null 은 캐시 장애(숨김)","example":120}},"required":["viewerCount"]},"ApiResponseDtoListEventLocationResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/EventLocationResponseDto"}}},"required":["data","developCode","message"]},"EventLocationResponseDto":{"type":"object","description":"행사 위치 하나 — 영역 격자·대표 격자·표시명 재료·영상 수","properties":{"locationId":{"type":"integer","format":"int64","description":"행사 위치 id — 위치별 영상 피드 진입 키","example":31},"name":{"type":"string","description":"위치 이름","example":"부산역 팝업"},"type":{"type":"string","description":"위치 유형 — 표시 라벨 변환은 FE 몫","enum":["POPUP","EXPERIENCE_ZONE","PARADE","PHOTO_ZONE","ETC"],"example":"POPUP"},"operatingHours":{"type":["string","null"],"description":"운영 시간 표시 문자열","example":"11:00 ~ 20:00"},"gridIds":{"type":"array","description":"영역을 구성하는 격자 전체 — FE 영역 채색 재료","example":["19443_9582"],"items":{"type":"string"}},"representativeGridId":{"type":"string","description":"대표 격자 — 이 위치의 영상이 붙는 단 하나의 격자","example":"19443_9582"},"zoneName":{"type":["string","null"],"description":"대표 격자가 속한 구역 이름. 구역 밖이면 null","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 안 위치 코드. 구역 밖이면 null","example":"A-14"},"regionName":{"type":["string","null"],"description":"대표 격자의 행정동 이름 — 구역 밖 표시명 폴백. 무귀속이면 null","example":"부전동"},"videoCount":{"type":"integer","format":"int64","description":"이 위치의 영상 수 — 조회 시점 실측(전역 노출 게이트 통과분)","example":7},"organizerName":{"type":["string","null"],"description":"운영 주체 — 참여형 승인분만 값이 있다","example":"필맵 주식회사"},"description":{"type":["string","null"],"description":"참여 소개 — 참여형 승인분만 값이 있다"},"participationStartsOn":{"type":["string","null"],"format":"date","description":"공개 시작일 (표기 정보, 노출 창 아님)","example":"2026-11-07"},"participationEndsOn":{"type":["string","null"],"format":"date","description":"공개 종료일 (표기 정보, 노출 창 아님)","example":"2026-11-09"},"participationMethod":{"type":["string","null"],"description":"참여 방식 서술 — 참여형 승인분만 값이 있다"},"imageUrl":{"type":["string","null"],"description":"커버 이미지 공개 URL — 참여형 승인분과 시드 위치에 값이 있다. 없으면 null 이고 나머지 필드는 그대로다"}},"required":["description","gridIds","imageUrl","locationId","name","operatingHours","organizerName","participationEndsOn","participationMethod","participationStartsOn","regionName","representativeGridId","type","videoCount","zoneCell","zoneName"]},"ApiResponseDtoEventLocationVideoPageResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/EventLocationVideoPageResponseDto"}},"required":["data","developCode","message"]},"EventLocationVideoPageResponseDto":{"type":"object","description":"위치별 영상 피드 페이지 (keyset 커서 페이지네이션)","properties":{"videos":{"type":"array","description":"이 페이지의 영상. 조건에 맞는 영상이 없으면 빈 배열","items":{"$ref":"#/components/schemas/EventLocationVideoResponseDto"}},"hasNext":{"type":"boolean","description":"다음 페이지 존재 여부 (lookahead 판정)"},"nextCursor":{"type":["string","null"],"description":"다음 페이지 조회용 opaque 커서. 다음 요청 cursor 파라미터에 그대로 넣는다. 마지막 페이지면 null"}},"required":["hasNext","nextCursor","videos"]},"EventLocationVideoResponseDto":{"type":"object","description":"위치별 영상 피드 항목","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID — 상세 진입 키","example":1042},"thumbnailUrl":{"type":"string","description":"썸네일 presigned GET URL"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초)","example":15},"createdAt":{"type":"string","format":"date-time","description":"업로드 시각","example":"2026-10-06T12:30:00Z"},"helpfulCount":{"type":"integer","format":"int64","description":"도움돼요 수","example":12},"commentCount":{"type":"integer","format":"int64","description":"댓글 수","example":3},"uploaderId":{"type":"integer","format":"int64","description":"작성자 사용자 ID (videos.user_id). 차단(POST /api/users/{userId}/block)의 경로 값","example":42}},"required":["commentCount","createdAt","durationSec","helpfulCount","thumbnailUrl","uploaderId","videoId"]},"ApiResponseDtoListRegionVideoResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RegionVideoResponseDto"}}},"required":["data","developCode","message"]},"RegionVideoResponseDto":{"type":"object","description":"동 단위 내 영상 리스트 항목 — 그 행정동 격자들에 올린 내 영상 하나.","properties":{"videoId":{"type":"integer","format":"int64","description":"영상(방문 이벤트) ID. 개별 재생·교체·삭제 진입 키","example":1042},"gridId":{"type":"string","description":"영상이 속한 격자 ID \"{grid_y}_{grid_x}\" — 항목별 격자 라벨·지도 이동용","example":"19422_9582"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL. READY 아니면(썸네일 key 없음) null"},"processingStatus":{"type":"string","description":"영상 처리 상태 (UPLOADED/ENCODING/BLURRING/READY/FAILED)","example":"READY"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"createdAt":{"type":"string","format":"date-time","description":"업로드(방문) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이 화면은 행정동 헤더 아래 목록이라 폴백 이름을 문맥에서 알 수 있어 항목에 regionName 을 따로 담지 않는다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["createdAt","durationSec","gridId","processingStatus","thumbnailUrl","videoId","zoneCell","zoneName"]},"ApiResponseDtoListUploadHistoryResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/UploadHistoryResponseDto"}}},"required":["data","developCode","message"]},"UploadHistoryResponseDto":{"type":"object","description":"날짜별 업로드 기록 항목 — 업로드가 있었던 KST 날짜 하나와 그날의 건수.","properties":{"uploadDate":{"type":"string","format":"date","description":"업로드가 있었던 KST 날짜","example":"2026-08-11"},"uploadCount":{"type":"integer","format":"int32","description":"그날 업로드한 영상 수 (1 이상)","example":3}},"required":["uploadCount","uploadDate"]},"ApiResponseDtoCollectionSummaryResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/CollectionSummaryResponseDto"}},"required":["data","developCode","message"]},"ApiResponseDtoListCollectionGridResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/CollectionGridResponseDto"}}},"required":["data","developCode","message"]},"CollectionGridResponseDto":{"type":"object","description":"갤러리 격자 항목 — 내가 수집한 격자 하나와 cover 썸네일.","properties":{"gridId":{"type":"string","description":"격자 ID \"{grid_y}_{grid_x}\"","example":"19422_9582"},"gridY":{"type":"integer","format":"int32","description":"격자 Y 인덱스(지도 이동용, gridId 디코드값)","example":19422},"gridX":{"type":"integer","format":"int32","description":"격자 X 인덱스(지도 이동용, gridId 디코드값)","example":9582},"firstCollectedAt":{"type":"string","format":"date-time","description":"최초 수집(점령) 시각 — 정렬 키","example":"2026-07-20T18:03:11Z"},"lastUploadedAt":{"type":"string","format":"date-time","description":"마지막 방문(업로드) 시각","example":"2026-07-21T09:12:00Z"},"videoCount":{"type":"integer","format":"int32","description":"그 격자 내 내 영상 수","example":3},"coverVideoId":{"type":["integer","null"],"format":"int64","description":"cover 영상 ID(없으면 null)","example":1042},"coverThumbnailUrl":{"type":["string","null"],"description":"cover 썸네일 presigned GET URL(없거나 READY 이전이면 null)"},"coverDurationSec":{"type":["integer","null"],"format":"int32","description":"cover 영상 길이(초) — 카드 duration 뱃지 재료. READY 이전에도 실리고 cover 자체가 없을 때만 null","example":12},"regionName":{"type":["string","null"],"description":"격자 중심점 행정동 이름(무귀속/미판정이면 null)","example":"서울특별시 강남구 역삼1동"},"zoneName":{"type":["string","null"],"description":"격자가 속한 구역 이름 (예 \"서면\"). 구역 밖 격자면 null — 이때 라벨은 regionName 이다","example":"서면"},"zoneCell":{"type":["string","null"],"description":"구역 내 위치 코드 \"{행}-{열}\" (행 A 는 구역 북단, 열 1 은 서단). zoneName 과 항상 쌍이라 구역 밖이면 함께 null","example":"I-6"}},"required":["coverDurationSec","coverThumbnailUrl","coverVideoId","firstCollectedAt","gridId","gridX","gridY","lastUploadedAt","regionName","videoCount","zoneCell","zoneName"]},"ApiResponseDtoListMyBadgeResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/MyBadgeResponseDto"}}},"required":["data","developCode","message"]},"MyBadgeResponseDto":{"type":"object","description":"내 뱃지 목록 행 — 획득+미획득 (은퇴 뱃지는 획득자에게만)","properties":{"badgeId":{"type":"integer","format":"int64","description":"뱃지 ID","example":2},"code":{"type":"string","description":"뱃지 code","example":"EXPLORER_10"},"name":{"type":"string","description":"표시명","example":"탐험가 I"},"description":{"type":["string","null"],"description":"설명 — badges.description 은 NULL 허용 컬럼이다","example":"격자 10개를 수집했어요"},"iconUrl":{"type":["string","null"],"description":"아이콘 URL (에셋 확정 전 null)","example":null},"earned":{"type":"boolean","description":"획득 여부","example":true},"earnedAt":{"type":["string","null"],"format":"date-time","description":"획득 시각 — 미획득이면 null","example":"2026-07-29T11:02:31Z"},"isNew":{"type":"boolean","description":"미확인(새 뱃지) 여부 — 미획득이면 false","example":false},"featuredRank":{"type":["integer","null"],"format":"int32","description":"대표 뱃지 순서(1·2) — 대표 아니면 null","example":1}},"required":["badgeId","code","description","earned","earnedAt","featuredRank","iconUrl","isNew","name"]},"ApiResponseDtoPasswordStatusResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/PasswordStatusResponseDto"}},"required":["data","developCode","message"]},"PasswordStatusResponseDto":{"type":"object","description":"비밀번호 강제 변경 상태","properties":{"mustChange":{"type":"boolean","description":"true 면 비밀번호를 바꾸기 전까지 행사 등재 콘솔이 막힌다","example":true}},"required":["mustChange"]},"AdminVideoReviewResponseDto":{"type":"object","description":"관리자 단건 영상 확인 응답 — 영상 메타와 재생·썸네일 presigned GET URL.","properties":{"videoId":{"type":"integer","format":"int64","description":"영상 ID","example":1042},"status":{"type":"string","description":"영상 상태 — BLINDED 여도 발급된다 (DELETED 만 404)","enum":["ACTIVE","BLINDED","DELETED"],"example":"BLINDED"},"processingStatus":{"type":"string","description":"영상 처리 상태 — READY 일 때만 재생 URL 이 발급된다","enum":["UPLOADED","ENCODING","BLURRING","READY","FAILED"],"example":"READY"},"visibility":{"type":"string","description":"공개 범위 — PRIVATE 여도 발급된다 (관리자 확인은 은닉 없음)","enum":["PUBLIC","PRIVATE","FRIENDS"],"example":"PRIVATE"},"durationSec":{"type":"integer","format":"int32","description":"영상 길이(초, 최대 30)","example":12},"recordedAt":{"type":"string","format":"date-time","description":"촬영 시각 (표시용)","example":"2026-07-20T18:03:11Z"},"playbackUrl":{"type":["string","null"],"description":"재생본 presigned GET URL — READY 가 아니면 null"},"thumbnailUrl":{"type":["string","null"],"description":"썸네일 presigned GET URL — 썸네일 key 없음(READY 이전)이면 null"},"expiresInSec":{"type":["integer","null"],"format":"int64","description":"playbackUrl presign TTL(초) — playbackUrl=null 이면 null","example":600}},"required":["durationSec","expiresInSec","playbackUrl","processingStatus","recordedAt","status","thumbnailUrl","videoId","visibility"]},"ApiResponseDtoAdminVideoReviewResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminVideoReviewResponseDto"}},"required":["data","developCode","message"]},"AdminReportItemResponseDto":{"type":"object","description":"관리자 신고 목록 항목 — 신고 한 건과 판단에 필요한 주변 정보.","properties":{"reportId":{"type":"integer","format":"int64","description":"신고 ID — 승인·기각 경로 변수로 그대로 쓴다","example":7},"status":{"type":"string","description":"신고 처리 상태","enum":["PENDING","REVIEWING","RESOLVED","REJECTED"],"example":"PENDING"},"reason":{"type":"string","description":"신고 사유","enum":["INAPPROPRIATE","PRIVACY","SPAM","COPYRIGHT","OTHER"],"example":"INAPPROPRIATE"},"detail":{"type":["string","null"],"description":"신고자가 적은 상세 설명 — OTHER 가 아닌 사유는 없을 수 있다"},"createdAt":{"type":"string","format":"date-time","description":"신고 접수 시각","example":"2026-08-06T10:15:00Z"},"reporterId":{"type":"integer","format":"int64","description":"신고자의 사용자 ID","example":3},"reporterNickname":{"type":"string","description":"신고자의 닉네임","example":"정민"},"videoId":{"type":"integer","format":"int64","description":"신고 대상 영상 ID — 단건 확인·블라인드 해제 경로 변수로 쓴다","example":1042},"videoStatus":{"type":"string","description":"신고 대상 영상의 현재 상태 (ACTIVE/BLINDED/DELETED)","enum":["ACTIVE","BLINDED","DELETED"],"example":"ACTIVE"},"videoOwnerNickname":{"type":"string","description":"영상 소유자의 닉네임","example":"성민"},"reviewedBy":{"type":["integer","null"],"format":"int64","description":"처리한 관리자의 사용자 ID — 미처리면 null","example":1},"reviewedAt":{"type":["string","null"],"format":"date-time","description":"처리 시각 — 미처리면 null","example":"2026-08-06T11:00:00Z"}},"required":["createdAt","detail","reason","reportId","reporterId","reporterNickname","reviewedAt","reviewedBy","status","videoId","videoOwnerNickname","videoStatus"]},"AdminReportListResponseDto":{"type":"object","description":"관리자 신고 목록 응답 — 상태 필터 기준 한 페이지.","properties":{"items":{"type":"array","description":"이 페이지의 신고 목록. 정렬은 접수 최신순 고정","items":{"$ref":"#/components/schemas/AdminReportItemResponseDto"}},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"totalElements":{"type":"integer","format":"int64","description":"필터에 해당하는 전체 신고 수","example":1},"totalPages":{"type":"integer","format":"int32","description":"전체 페이지 수","example":1}},"required":["items","page","size","totalElements","totalPages"]},"ApiResponseDtoAdminReportListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReportListResponseDto"}},"required":["data","developCode","message"]},"AdminOrgAccountItemResponseDto":{"type":"object","description":"발급된 행사 운영자 계정","properties":{"userId":{"type":"integer","format":"int64","description":"계정 id","example":42},"orgName":{"type":["string","null"],"description":"기관명. 이 발급 경로 밖에서 만들어진 계정이면 null 일 수 있다","example":"부산진구청"},"contactName":{"type":"string","description":"담당자 이름","example":"김담당"},"email":{"type":"string","description":"공식 이메일 (계정 아이디)","example":"event@busanjin.go.kr"},"contactPhone":{"type":["string","null"],"description":"담당자 연락처. 직접 발급에서 생략했으면 null 이다","example":"010-1234-5678"},"provider":{"type":"string","description":"로그인 제공자. 목록이 LOCAL 만 담는다는 사실의 확인 재료다","example":"LOCAL"},"mustChange":{"type":"boolean","description":"초기 비밀번호 변경 강제 여부. true 면 초기 로그인 전, false 면 사용 중이다","example":true},"createdAt":{"type":"string","format":"date-time","description":"발급 시각"}},"required":["contactName","contactPhone","createdAt","email","mustChange","orgName","provider","userId"]},"AdminOrgAccountListResponseDto":{"type":"object","description":"발급된 행사 운영자 계정 목록 — 발급 최신순 한 페이지.","properties":{"totalElements":{"type":"integer","format":"int64","description":"조건에 해당하는 전체 계정 수","example":12},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"accounts":{"type":"array","description":"이 페이지의 계정 목록. 정렬은 발급 최신순 고정","items":{"$ref":"#/components/schemas/AdminOrgAccountItemResponseDto"}}},"required":["accounts","page","size","totalElements"]},"ApiResponseDtoAdminOrgAccountListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminOrgAccountListResponseDto"}},"required":["data","developCode","message"]},"AdminOrgAccountRequestItemResponseDto":{"type":"object","description":"계정 발급 요청 목록 항목","properties":{"id":{"type":"integer","format":"int64","description":"요청 id","example":7},"orgName":{"type":"string","description":"기관명","example":"부산진구청"},"contactName":{"type":"string","description":"담당자 이름","example":"김담당"},"email":{"type":"string","description":"공식 이메일","example":"event@busanjin.go.kr"},"eventName":{"type":"string","description":"예정 행사명","example":"서면 겨울 축제"},"status":{"type":"string","description":"처리 상태 (PENDING, ISSUED, REJECTED)","example":"PENDING"},"createdAt":{"type":"string","format":"date-time","description":"최초 접수 시각"},"updatedAt":{"type":"string","format":"date-time","description":"마지막 접수 시각 — 정렬 기준이자 심사의 검토 기준 시각"}},"required":["contactName","createdAt","email","eventName","id","orgName","status","updatedAt"]},"AdminOrgAccountRequestListResponseDto":{"type":"object","description":"계정 발급 요청 목록 — 상태 필터 기준 한 페이지와 상태별 전체 건수.","properties":{"pendingCount":{"type":"integer","format":"int64","description":"대기 건수 (필터와 무관한 전체 집계)","example":3},"issuedCount":{"type":"integer","format":"int64","description":"발급됨 건수 (필터와 무관한 전체 집계)","example":12},"rejectedCount":{"type":"integer","format":"int64","description":"반려 건수 (필터와 무관한 전체 집계)","example":2},"totalElements":{"type":"integer","format":"int64","description":"필터에 해당하는 전체 요청 수","example":3},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"requests":{"type":"array","description":"이 페이지의 요청 목록. 정렬은 마지막 접수 최신순 고정","items":{"$ref":"#/components/schemas/AdminOrgAccountRequestItemResponseDto"}}},"required":["issuedCount","page","pendingCount","rejectedCount","requests","size","totalElements"]},"ApiResponseDtoAdminOrgAccountRequestListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminOrgAccountRequestListResponseDto"}},"required":["data","developCode","message"]},"AdminOrgAccountRequestDetailResponseDto":{"type":"object","description":"계정 발급 요청 상세","properties":{"id":{"type":"integer","format":"int64","description":"요청 id","example":7},"orgName":{"type":"string","description":"기관명","example":"부산진구청"},"contactName":{"type":"string","description":"담당자 이름","example":"김담당"},"contactPhone":{"type":"string","description":"담당자 연락처","example":"010-1234-5678"},"email":{"type":"string","description":"공식 이메일","example":"event@busanjin.go.kr"},"eventName":{"type":"string","description":"예정 행사명","example":"서면 겨울 축제"},"content":{"type":"string","description":"요청 내용"},"status":{"type":"string","description":"처리 상태 (PENDING, ISSUED, REJECTED)","example":"PENDING"},"rejectReason":{"type":["string","null"],"description":"반려 사유. 반려 건에만 값이 있다"},"issuedUserId":{"type":["integer","null"],"format":"int64","description":"발급된 계정 id. 발급 건에만 값이 있다","example":42},"createdAt":{"type":"string","format":"date-time","description":"최초 접수 시각"},"updatedAt":{"type":"string","format":"date-time","description":"마지막 접수 시각 — 승인·반려 요청에 그대로 에코해야 하는 검토 기준 시각"},"processedAt":{"type":["string","null"],"format":"date-time","description":"처리 시각. 승인·반려 건에만 값이 있다"}},"required":["contactName","contactPhone","content","createdAt","email","eventName","id","issuedUserId","orgName","processedAt","rejectReason","status","updatedAt"]},"ApiResponseDtoAdminOrgAccountRequestDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminOrgAccountRequestDetailResponseDto"}},"required":["data","developCode","message"]},"AdminApprovedEventItemResponseDto":{"type":"object","description":"승인 행사 목록 항목","properties":{"submissionId":{"type":"integer","format":"int64","description":"승인 행사 식별자 (= 신청 id)","example":7},"approvalNo":{"type":"string","description":"승인 번호","example":"APR-2026-0001"},"submissionNo":{"type":"string","description":"신청 번호","example":"FM-2026-0007"},"type":{"type":"string","description":"등록 유형","enum":["FESTIVAL","POPUP","EVENT"],"example":"FESTIVAL"},"title":{"type":"string","description":"축제명 / 팝업명","example":"부산불꽃축제"},"organizerName":{"type":"string","description":"주최 기관 — 신청 폼에 적힌 값"},"orgName":{"type":["string","null"],"description":"기관명 — 신청 계정에 등록된 값"},"startsOn":{"type":"string","format":"date","description":"행사 시작일","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일","example":"2026-11-09"},"status":{"type":"string","description":"파생 상태 (UPCOMING 예정 · EXPOSED 노출 중 · ENDED 종료)","example":"EXPOSED"},"unpublished":{"type":"boolean","description":"노출 중지 여부","example":false},"unpublishedAt":{"type":["string","null"],"format":"date-time","description":"노출 중지 시각 (UTC) — 중지되지 않았으면 null"},"unpublishReason":{"type":["string","null"],"description":"노출 중지 사유 — 중지되지 않았으면 null"}},"required":["approvalNo","endsOn","orgName","organizerName","startsOn","status","submissionId","submissionNo","title","type","unpublishReason","unpublished","unpublishedAt"]},"AdminApprovedEventListResponseDto":{"type":"object","description":"승인 행사 목록 — 탭 기준 한 페이지와 탭별 전체 건수.","properties":{"exposedCount":{"type":"integer","format":"int64","description":"노출 중 건수 (탭과 무관한 전체 집계)","example":4},"upcomingCount":{"type":"integer","format":"int64","description":"예정 건수 (탭과 무관한 전체 집계)","example":2},"endedCount":{"type":"integer","format":"int64","description":"종료 건수 (탭과 무관한 전체 집계)","example":9},"totalElements":{"type":"integer","format":"int64","description":"탭에 해당하는 전체 행사 수","example":4},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"events":{"type":"array","description":"이 페이지의 행사 목록. 정렬은 시작일 최신순 고정","items":{"$ref":"#/components/schemas/AdminApprovedEventItemResponseDto"}}},"required":["endedCount","events","exposedCount","page","size","totalElements","upcomingCount"]},"ApiResponseDtoAdminApprovedEventListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminApprovedEventListResponseDto"}},"required":["data","developCode","message"]},"AdminEventSubmissionItemResponseDto":{"type":"object","description":"관리자 심사 큐 항목","properties":{"id":{"type":"integer","format":"int64","description":"신청 id","example":7},"submissionNo":{"type":"string","description":"신청 번호","example":"FM-2026-0007"},"type":{"type":"string","description":"등록 유형","enum":["FESTIVAL","POPUP","EVENT"],"example":"FESTIVAL"},"status":{"type":"string","description":"신청 상태","enum":["IN_REVIEW","APPROVED","REJECTED"],"example":"IN_REVIEW"},"title":{"type":"string","description":"축제명 / 팝업명","example":"부산불꽃축제"},"organizerName":{"type":"string","description":"주최 기관 — 신청 폼에 적힌 값","example":"부산문화관광축제조직위원회"},"orgName":{"type":["string","null"],"description":"기관명 — 신청 계정에 등록된 값","example":"부산광역시 부산진구청"},"startsOn":{"type":"string","format":"date","description":"행사 시작일","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일","example":"2026-11-07"},"locationCount":{"type":"integer","format":"int32","description":"신청에 담긴 위치 수","example":2},"createdAt":{"type":"string","format":"date-time","description":"접수 시각 (UTC)","example":"2026-08-28T02:00:00Z"},"updatedAt":{"type":"string","format":"date-time","description":"마지막 변경 시각 (UTC)","example":"2026-08-28T02:11:00Z"}},"required":["createdAt","endsOn","id","locationCount","orgName","organizerName","startsOn","status","submissionNo","title","type","updatedAt"]},"AdminEventSubmissionListResponseDto":{"type":"object","description":"관리자 심사 큐 — 상태 필터 기준 한 페이지와 상태별 전체 건수.","properties":{"counts":{"$ref":"#/components/schemas/EventSubmissionStatusCountsResponseDto","description":"상태별 전체 건수 (필터와 무관)"},"totalElements":{"type":"integer","format":"int64","description":"필터에 해당하는 전체 신청 수","example":3},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"submissions":{"type":"array","description":"이 페이지의 신청 목록. 정렬은 접수 최신순 고정","items":{"$ref":"#/components/schemas/AdminEventSubmissionItemResponseDto"}}},"required":["counts","page","size","submissions","totalElements"]},"ApiResponseDtoAdminEventSubmissionListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminEventSubmissionListResponseDto"}},"required":["data","developCode","message"]},"AdminEventSubmissionDetailResponseDto":{"type":"object","description":"관리자 심사 상세","properties":{"id":{"type":"integer","format":"int64","description":"신청 id","example":7},"submissionNo":{"type":"string","description":"신청 번호","example":"FM-2026-0007"},"type":{"type":"string","description":"등록 유형","example":"FESTIVAL"},"status":{"type":"string","description":"신청 상태","example":"IN_REVIEW"},"title":{"type":"string","description":"축제명 / 팝업명"},"organizerName":{"type":"string","description":"주최 기관 — 신청 폼에 적힌 값"},"startsOn":{"type":"string","format":"date","description":"행사 시작일","example":"2026-11-07"},"endsOn":{"type":"string","format":"date","description":"행사 종료일","example":"2026-11-07"},"operatingHours":{"type":["string","null"],"description":"운영 시간 — POPUP 만 값이 있다"},"programDescription":{"type":["string","null"],"description":"주요 프로그램 — FESTIVAL 만 값이 있다"},"participationMethod":{"type":["string","null"],"description":"참여 방식 — EVENT(참여형)만 값이 있다"},"parentEvent":{"anyOf":[{"$ref":"#/components/schemas/EventSubmissionParentEventResponseDto"},{"type":"null"}],"description":"참여할 부모 이벤트 회차 — EVENT(참여형)만 값이 있다"},"description":{"type":"string","description":"행사 소개"},"imageUrl":{"type":"string","description":"대표 이미지 열람용 presigned GET URL"},"orgName":{"type":["string","null"],"description":"신청 계정의 기관명","example":"부산광역시 부산진구청"},"contactName":{"type":"string","description":"신청 계정의 담당자 이름","example":"김담당"},"email":{"type":"string","description":"신청 계정의 공식 이메일 (로그인 아이디)","example":"event@busanjin.go.kr"},"locations":{"type":"array","description":"위치 목록 — 순번 오름차순","items":{"$ref":"#/components/schemas/EventSubmissionLocationResponseDto"}},"exposureRect":{"$ref":"#/components/schemas/EventSubmissionAreaRectDto","description":"전 위치 셀 합집합의 경계 사각형 — 조회 시점 계산이고 저장하지 않는다"},"history":{"type":"array","description":"상태 이력 — 발생 순","items":{"$ref":"#/components/schemas/EventSubmissionHistoryResponseDto"}},"createdAt":{"type":"string","format":"date-time","description":"접수 시각 (UTC)","example":"2026-08-28T02:00:00Z"},"updatedAt":{"type":"string","format":"date-time","description":"마지막 변경 시각 (UTC)","example":"2026-08-28T02:11:00Z"}},"required":["contactName","createdAt","description","email","endsOn","exposureRect","history","id","imageUrl","locations","operatingHours","orgName","organizerName","parentEvent","participationMethod","programDescription","startsOn","status","submissionNo","title","type","updatedAt"]},"ApiResponseDtoAdminEventSubmissionDetailResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminEventSubmissionDetailResponseDto"}},"required":["data","developCode","message"]},"AdminEmailChangeRequestItemResponseDto":{"type":"object","description":"아이디 변경 요청 큐 항목","properties":{"id":{"type":"integer","format":"int64","description":"요청 id","example":3},"userId":{"type":"integer","format":"int64","description":"요청한 계정 id","example":42},"orgName":{"type":["string","null"],"description":"기관명","example":"부산광역시 부산진구청"},"email":{"type":"string","description":"현재 아이디(로그인 이메일)","example":"event@busanjin.go.kr"},"requestedEmail":{"type":"string","description":"바꾸려는 이메일","example":"festival@busanjin.go.kr"},"status":{"type":"string","description":"처리 상태","enum":["PENDING","APPROVED","REJECTED"],"example":"PENDING"},"createdAt":{"type":"string","format":"date-time","description":"마지막 접수 시각 (UTC) — 승인·반려 요청에 되돌려 보내는 검토 기준 시각","example":"2026-08-28T02:00:00Z"},"processedAt":{"type":["string","null"],"format":"date-time","description":"처리 시각 (UTC) — 대기 중이면 null"},"rejectReason":{"type":["string","null"],"description":"반려 사유 — 반려된 요청에만 있다"}},"required":["createdAt","email","id","orgName","processedAt","rejectReason","requestedEmail","status","userId"]},"AdminEmailChangeRequestListResponseDto":{"type":"object","description":"아이디 변경 요청 목록 — 상태 필터 기준 한 페이지와 상태별 전체 건수.","properties":{"pendingCount":{"type":"integer","format":"int64","description":"대기 건수 (필터와 무관한 전체 집계)","example":2},"approvedCount":{"type":"integer","format":"int64","description":"승인 건수 (필터와 무관한 전체 집계)","example":7},"rejectedCount":{"type":"integer","format":"int64","description":"반려 건수 (필터와 무관한 전체 집계)","example":1},"totalElements":{"type":"integer","format":"int64","description":"필터에 해당하는 전체 요청 수","example":2},"page":{"type":"integer","format":"int32","description":"현재 페이지 번호 (0부터)","example":0},"size":{"type":"integer","format":"int32","description":"페이지 크기","example":20},"requests":{"type":"array","description":"이 페이지의 요청 목록. 정렬은 마지막 접수 최신순 고정","items":{"$ref":"#/components/schemas/AdminEmailChangeRequestItemResponseDto"}}},"required":["approvedCount","page","pendingCount","rejectedCount","requests","size","totalElements"]},"ApiResponseDtoAdminEmailChangeRequestListResponseDto":{"type":"object","properties":{"developCode":{"type":"integer","format":"int32"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminEmailChangeRequestListResponseDto"}},"required":["data","developCode","message"]}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}}
\ No newline at end of file
diff --git a/apps/web/src/entities/cell/model/mock-cells.ts b/apps/web/src/entities/cell/model/mock-cells.ts
index 259dcbf5..35ede908 100644
--- a/apps/web/src/entities/cell/model/mock-cells.ts
+++ b/apps/web/src/entities/cell/model/mock-cells.ts
@@ -74,6 +74,7 @@ const buildVideos = (videoIdBase: number, sampleSize: number): CellVideo[] =>
thumbnailUrl: VIDEO_THUMBS[i % VIDEO_THUMBS.length],
// 명세 추가 필드(2026-08-11 스냅샷) — 화면 미사용, 목-명세 정렬용
nickname: VIDEO_HANDLES[i % VIDEO_HANDLES.length].slice(1),
+ userId: 1000 + (i % VIDEO_HANDLES.length),
} satisfies GridGlobalVideoResponseDto),
// FE 확장 필드 (CellVideoExtension) — 명세 대응 없음
title: VIDEO_TITLES[i % VIDEO_TITLES.length],
diff --git a/apps/web/src/features/event/model/use-location-videos-query.test.tsx b/apps/web/src/features/event/model/use-location-videos-query.test.tsx
index b28bb3ef..6e5e3e89 100644
--- a/apps/web/src/features/event/model/use-location-videos-query.test.tsx
+++ b/apps/web/src/features/event/model/use-location-videos-query.test.tsx
@@ -27,6 +27,7 @@ const wrapper = ({ children }: { children: ReactNode }) => (
const video = (videoId: number): EventLocationVideoResponseDto => ({
videoId,
+ uploaderId: 7,
thumbnailUrl: `https://cdn.example.com/thumb-${videoId}.jpg`,
durationSec: 24,
createdAt: "2026-08-31T10:00:00+09:00",
diff --git a/apps/web/src/features/map-home/model/grid-videos.test.ts b/apps/web/src/features/map-home/model/grid-videos.test.ts
index e5a0867c..bbc4a07f 100644
--- a/apps/web/src/features/map-home/model/grid-videos.test.ts
+++ b/apps/web/src/features/map-home/model/grid-videos.test.ts
@@ -18,6 +18,7 @@ import {
const GLOBAL_DTO: GridGlobalVideoResponseDto = {
videoId: 1042,
+ userId: 2042,
thumbnailUrl: "https://cdn.example/thumb-1042.jpg",
durationSec: 27,
viewCount: 1400,
diff --git a/apps/web/src/pages/map-home/ui/event-archive-body.smoke.test.tsx b/apps/web/src/pages/map-home/ui/event-archive-body.smoke.test.tsx
index 0d10a64b..36f63727 100644
--- a/apps/web/src/pages/map-home/ui/event-archive-body.smoke.test.tsx
+++ b/apps/web/src/pages/map-home/ui/event-archive-body.smoke.test.tsx
@@ -217,6 +217,7 @@ describe("종료 행사 아카이브 본문 (MSG-519)", () => {
*/
const archiveVideo = (videoId: number): EventLocationVideoResponseDto => ({
videoId,
+ uploaderId: 7,
thumbnailUrl: `https://cdn.example.com/thumb-${videoId}.jpg`,
durationSec: 24,
createdAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
diff --git a/apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx b/apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx
index 69dc9db2..6800c64c 100644
--- a/apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx
+++ b/apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx
@@ -33,6 +33,7 @@ const video = (
over: Partial = {},
): EventLocationVideoResponseDto => ({
videoId,
+ uploaderId: 7,
thumbnailUrl: `https://cdn.example.com/thumb-${videoId}.jpg`,
durationSec: 24,
createdAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
diff --git a/apps/web/src/pages/org/submission-edit.smoke.test.tsx b/apps/web/src/pages/org/submission-edit.smoke.test.tsx
index ead9fb56..15ec8b0c 100644
--- a/apps/web/src/pages/org/submission-edit.smoke.test.tsx
+++ b/apps/web/src/pages/org/submission-edit.smoke.test.tsx
@@ -319,7 +319,8 @@ describe("수정 진입 가드 (AC 8)", () => {
fireEvent.click(screen.getByRole("link", { name: "다른 신청 수정으로" }));
expect(await screen.findByText("신청 상세 화면")).toBeDefined();
- expect(detailVisits).toEqual(["13"]);
+ // 방문 기록은 passive effect라 텍스트 출현보다 늦을 수 있다 — develop CI 4d0d698 플레이크
+ await waitFor(() => expect(detailVisits).toEqual(["13"]));
});
it("`/new`로 나갔다 같은 신청의 /edit로 돌아오면 가드가 다시 판정한다 (AC 8 — 허가는 방문 단위, codex P2)", async () => {
@@ -363,7 +364,7 @@ describe("수정 진입 가드 (AC 8)", () => {
fireEvent.click(screen.getByRole("link", { name: "이 신청 수정으로" }));
expect(await screen.findByText("신청 상세 화면")).toBeDefined();
- expect(detailVisits).toEqual(["12"]);
+ await waitFor(() => expect(detailVisits).toEqual(["12"]));
});
it("비숫자 신청 번호는 요청을 발사하지 않고 오류를 안내한다 (AC 8 — 549 선례)", () => {
diff --git a/apps/web/src/shared/api/generated/@tanstack/react-query.gen.ts b/apps/web/src/shared/api/generated/@tanstack/react-query.gen.ts
index c93b7038..9f13415d 100644
--- a/apps/web/src/shared/api/generated/@tanstack/react-query.gen.ts
+++ b/apps/web/src/shared/api/generated/@tanstack/react-query.gen.ts
@@ -3,8 +3,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
-import { accept, addHelpful, approve, approve1, approve2, approveEmailChange, changePassword, create, createComment, delete_, deleteComment, deleteFriend, deleteMe, findMyBadges, getAccounts, getActiveMissionsInViewport, getApprovedEvents, getCell, getCollectionGrids, getComments, getConsentStatus, getDistricts, getEmailChangeRequests, getEventLocationsByGrid, getEvents, getExploreRegions, getFriendGridAggregates, getFriendGrids, getFriendGridVideos, getFriendProfile, getFriends, getGridCover, getGridGlobalVideos, getGridHourlyUploads, getGridVideos, getHotZoneAggregates, getHotZones, getInbox, getLocations, getLocationVideos, getMe, getMissionAggregates, getMissionDetail, getMissionsByGrid, getMissionVideos, getMyFriendCode, getMyProgress, getMySubmissions, getNationalStat, getOccupiedAggregatesInViewport, getOccupiedInViewport, getOccurrenceDetail, getOccurrencesInViewport, getPlayback, getPreferences, getProfile, getReceivedRequests, getRegionGrids, getRegionVideos, getReports, getRequest, getRequests, getStatByGrid, getStatByPoint, getStats, getStatus, getSubmission, getSubmission1, getSubmissions, getSummary, getTrendingKeywords, getUnreadCount, getUploadHistory, getVideoDetail, getVideoForReview, getViewerCount, getZones, heartbeat, highlightPreview, issueDirect, issueImagePresignedUrl, issuePresignedUrl, issueProfileImagePresignedUrl, login, logout, markAllRead, markRead, oauthCodeLogin, oauthLogin, type Options, preview, recommend, redirectToKakaoAuthorize, register, reissue, reject, reject1, reject2, reject3, rejectEmailChange, removeHelpful, removeProfileImage, replace, replaceFeatured, report, request, requestEmailChange, requestReset, resendPassword, resetPassword, resubmit, reverseGeocode, searchPlaces, setInitialPassword, setVisibility, signup, socialLogin, submit, submitConsents, unblindVideo, unpublish, unregister, update, updateComment, updateLocationConsent, updateMarketingConsent, updateNickname, updateProfile, updateProfileImage, updateSubscription, upload, upload1, uploadMissionVideo, walkPaths } from '../sdk.gen';
-import type { AcceptData, AddHelpfulData, AddHelpfulResponse, Approve1Data, Approve1Response, Approve2Data, Approve2Response, ApproveData, ApproveEmailChangeData, ApproveEmailChangeResponse, ApproveResponse, ChangePasswordData, CreateCommentData, CreateCommentResponse, CreateData, DeleteCommentData, DeleteData, DeleteFriendData, DeleteMeData, FindMyBadgesData, FindMyBadgesResponse, GetAccountsData, GetAccountsResponse, GetActiveMissionsInViewportData, GetActiveMissionsInViewportResponse, GetApprovedEventsData, GetApprovedEventsResponse, GetCellData, GetCellResponse, GetCollectionGridsData, GetCollectionGridsResponse, GetCommentsData, GetCommentsResponse, GetConsentStatusData, GetConsentStatusResponse, GetDistrictsData, GetDistrictsResponse, GetEmailChangeRequestsData, GetEmailChangeRequestsResponse, GetEventLocationsByGridData, GetEventLocationsByGridResponse, GetEventsData, GetEventsResponse, GetExploreRegionsData, GetExploreRegionsResponse, GetFriendGridAggregatesData, GetFriendGridAggregatesResponse, GetFriendGridsData, GetFriendGridsResponse, GetFriendGridVideosData, GetFriendGridVideosResponse, GetFriendProfileData, GetFriendProfileResponse, GetFriendsData, GetFriendsResponse, GetGridCoverData, GetGridCoverResponse, GetGridGlobalVideosData, GetGridGlobalVideosResponse, GetGridHourlyUploadsData, GetGridHourlyUploadsResponse, GetGridVideosData, GetGridVideosResponse, GetHotZoneAggregatesData, GetHotZoneAggregatesResponse, GetHotZonesData, GetHotZonesResponse, GetInboxData, GetInboxResponse, GetLocationsData, GetLocationsResponse, GetLocationVideosData, GetLocationVideosResponse, GetMeData, GetMeResponse, GetMissionAggregatesData, GetMissionAggregatesResponse, GetMissionDetailData, GetMissionDetailResponse, GetMissionsByGridData, GetMissionsByGridResponse, GetMissionVideosData, GetMissionVideosResponse, GetMyFriendCodeData, GetMyFriendCodeResponse, GetMyProgressData, GetMyProgressResponse, GetMySubmissionsData, GetMySubmissionsResponse, GetNationalStatData, GetNationalStatResponse, GetOccupiedAggregatesInViewportData, GetOccupiedAggregatesInViewportResponse, GetOccupiedInViewportData, GetOccupiedInViewportResponse, GetOccurrenceDetailData, GetOccurrenceDetailResponse, GetOccurrencesInViewportData, GetOccurrencesInViewportResponse, GetPlaybackData, GetPlaybackResponse, GetPreferencesData, GetPreferencesResponse, GetProfileData, GetProfileResponse, GetReceivedRequestsData, GetReceivedRequestsResponse, GetRegionGridsData, GetRegionGridsResponse, GetRegionVideosData, GetRegionVideosResponse, GetReportsData, GetReportsResponse, GetRequestData, GetRequestResponse, GetRequestsData, GetRequestsResponse, GetStatByGridData, GetStatByGridResponse, GetStatByPointData, GetStatByPointResponse, GetStatsData, GetStatsResponse, GetStatusData, GetStatusResponse, GetSubmission1Data, GetSubmission1Response, GetSubmissionData, GetSubmissionResponse, GetSubmissionsData, GetSubmissionsResponse, GetSummaryData, GetSummaryResponse, GetTrendingKeywordsData, GetTrendingKeywordsResponse, GetUnreadCountData, GetUnreadCountResponse, GetUploadHistoryData, GetUploadHistoryResponse, GetVideoDetailData, GetVideoDetailResponse, GetVideoForReviewData, GetVideoForReviewResponse, GetViewerCountData, GetViewerCountResponse, GetZonesData, GetZonesResponse, HeartbeatData, HighlightPreviewData, HighlightPreviewResponse, IssueDirectData, IssueDirectResponse, IssueImagePresignedUrlData, IssueImagePresignedUrlResponse, IssuePresignedUrlData, IssuePresignedUrlResponse, IssueProfileImagePresignedUrlData, IssueProfileImagePresignedUrlResponse, LoginData, LoginResponse, LogoutData, MarkAllReadData, MarkReadData, OauthCodeLoginData, OauthCodeLoginResponse, OauthLoginData, OauthLoginResponse, PreviewData, PreviewResponse, RecommendData, RecommendResponse, RedirectToKakaoAuthorizeData, RegisterData, ReissueData, ReissueResponse, Reject1Data, Reject1Response, Reject2Data, Reject3Data, RejectData, RejectEmailChangeData, RemoveHelpfulData, RemoveHelpfulResponse, RemoveProfileImageData, RemoveProfileImageResponse, ReplaceData, ReplaceFeaturedData, ReplaceFeaturedResponse, ReplaceResponse, ReportData, ReportResponse, RequestData, RequestEmailChangeData, RequestResetData, RequestResponse, ResendPasswordData, ResendPasswordResponse, ResetPasswordData, ResubmitData, ResubmitResponse, ReverseGeocodeData, ReverseGeocodeResponse, SearchPlacesData, SearchPlacesResponse, SetInitialPasswordData, SetVisibilityData, SetVisibilityResponse, SignupData, SignupResponse, SocialLoginData, SocialLoginResponse, SubmitConsentsData, SubmitConsentsResponse, SubmitData, SubmitResponse, UnblindVideoData, UnblindVideoResponse, UnpublishData, UnpublishResponse, UnregisterData, UpdateCommentData, UpdateCommentResponse, UpdateData, UpdateLocationConsentData, UpdateLocationConsentResponse, UpdateMarketingConsentData, UpdateMarketingConsentResponse, UpdateNicknameData, UpdateNicknameResponse, UpdateProfileData, UpdateProfileImageData, UpdateProfileImageResponse, UpdateProfileResponse, UpdateResponse, UpdateSubscriptionData, UpdateSubscriptionResponse, Upload1Data, Upload1Response, UploadData, UploadMissionVideoData, UploadMissionVideoResponse, UploadResponse, WalkPathsData, WalkPathsResponse } from '../types.gen';
+import { accept, addHelpful, approve, approve1, approve2, approveEmailChange, block, changePassword, create, createComment, delete_, deleteComment, deleteFriend, deleteMe, findMyBadges, getAccounts, getActiveMissionsInViewport, getApprovedEvents, getBlockedUsers, getCell, getCollectionGrids, getComments, getConsentStatus, getDistricts, getEmailChangeRequests, getEventLocationsByGrid, getEvents, getExploreRegions, getFriendGridAggregates, getFriendGrids, getFriendGridVideos, getFriendProfile, getFriends, getGridCover, getGridGlobalVideos, getGridHourlyUploads, getGridVideos, getHotZoneAggregates, getHotZones, getInbox, getLocations, getLocationVideos, getMe, getMissionAggregates, getMissionDetail, getMissionsByGrid, getMissionVideos, getMyFriendCode, getMyProgress, getMySubmissions, getNationalStat, getOccupiedAggregatesInViewport, getOccupiedInViewport, getOccurrenceDetail, getOccurrencesInViewport, getPlayback, getPreferences, getProfile, getReceivedRequests, getRegionGrids, getRegionVideos, getReports, getRequest, getRequests, getStatByGrid, getStatByPoint, getStats, getStatus, getSubmission, getSubmission1, getSubmissions, getSummary, getTrendingKeywords, getUnreadCount, getUploadHistory, getVideoDetail, getVideoForReview, getViewerCount, getZones, heartbeat, highlightPreview, issueDirect, issueImagePresignedUrl, issuePresignedUrl, issueProfileImagePresignedUrl, login, logout, markAllRead, markRead, oauthCodeLogin, oauthLogin, type Options, preview, probe, probe1, recommend, redirectToKakaoAuthorize, register, reissue, reject, reject1, reject2, reject3, rejectEmailChange, removeHelpful, removeProfileImage, replace, replaceFeatured, report, request, requestEmailChange, requestReset, resendPassword, resetPassword, resubmit, reverseGeocode, searchPlaces, setInitialPassword, setVisibility, signup, socialLogin, submit, submitConsents, unblindVideo, unblock, unpublish, unregister, update, updateComment, updateLocationConsent, updateMarketingConsent, updateNickname, updateProfile, updateProfileImage, updateSubscription, upload, upload1, uploadMissionVideo, walkPaths, whoami } from '../sdk.gen';
+import type { AcceptData, AddHelpfulData, AddHelpfulResponse, Approve1Data, Approve1Response, Approve2Data, Approve2Response, ApproveData, ApproveEmailChangeData, ApproveEmailChangeResponse, ApproveResponse, BlockData, ChangePasswordData, CreateCommentData, CreateCommentResponse, CreateData, DeleteCommentData, DeleteData, DeleteFriendData, DeleteMeData, FindMyBadgesData, FindMyBadgesResponse, GetAccountsData, GetAccountsResponse, GetActiveMissionsInViewportData, GetActiveMissionsInViewportResponse, GetApprovedEventsData, GetApprovedEventsResponse, GetBlockedUsersData, GetBlockedUsersResponse, GetCellData, GetCellResponse, GetCollectionGridsData, GetCollectionGridsResponse, GetCommentsData, GetCommentsResponse, GetConsentStatusData, GetConsentStatusResponse, GetDistrictsData, GetDistrictsResponse, GetEmailChangeRequestsData, GetEmailChangeRequestsResponse, GetEventLocationsByGridData, GetEventLocationsByGridResponse, GetEventsData, GetEventsResponse, GetExploreRegionsData, GetExploreRegionsResponse, GetFriendGridAggregatesData, GetFriendGridAggregatesResponse, GetFriendGridsData, GetFriendGridsResponse, GetFriendGridVideosData, GetFriendGridVideosResponse, GetFriendProfileData, GetFriendProfileResponse, GetFriendsData, GetFriendsResponse, GetGridCoverData, GetGridCoverResponse, GetGridGlobalVideosData, GetGridGlobalVideosResponse, GetGridHourlyUploadsData, GetGridHourlyUploadsResponse, GetGridVideosData, GetGridVideosResponse, GetHotZoneAggregatesData, GetHotZoneAggregatesResponse, GetHotZonesData, GetHotZonesResponse, GetInboxData, GetInboxResponse, GetLocationsData, GetLocationsResponse, GetLocationVideosData, GetLocationVideosResponse, GetMeData, GetMeResponse, GetMissionAggregatesData, GetMissionAggregatesResponse, GetMissionDetailData, GetMissionDetailResponse, GetMissionsByGridData, GetMissionsByGridResponse, GetMissionVideosData, GetMissionVideosResponse, GetMyFriendCodeData, GetMyFriendCodeResponse, GetMyProgressData, GetMyProgressResponse, GetMySubmissionsData, GetMySubmissionsResponse, GetNationalStatData, GetNationalStatResponse, GetOccupiedAggregatesInViewportData, GetOccupiedAggregatesInViewportResponse, GetOccupiedInViewportData, GetOccupiedInViewportResponse, GetOccurrenceDetailData, GetOccurrenceDetailResponse, GetOccurrencesInViewportData, GetOccurrencesInViewportResponse, GetPlaybackData, GetPlaybackResponse, GetPreferencesData, GetPreferencesResponse, GetProfileData, GetProfileResponse, GetReceivedRequestsData, GetReceivedRequestsResponse, GetRegionGridsData, GetRegionGridsResponse, GetRegionVideosData, GetRegionVideosResponse, GetReportsData, GetReportsResponse, GetRequestData, GetRequestResponse, GetRequestsData, GetRequestsResponse, GetStatByGridData, GetStatByGridResponse, GetStatByPointData, GetStatByPointResponse, GetStatsData, GetStatsResponse, GetStatusData, GetStatusResponse, GetSubmission1Data, GetSubmission1Response, GetSubmissionData, GetSubmissionResponse, GetSubmissionsData, GetSubmissionsResponse, GetSummaryData, GetSummaryResponse, GetTrendingKeywordsData, GetTrendingKeywordsResponse, GetUnreadCountData, GetUnreadCountResponse, GetUploadHistoryData, GetUploadHistoryResponse, GetVideoDetailData, GetVideoDetailResponse, GetVideoForReviewData, GetVideoForReviewResponse, GetViewerCountData, GetViewerCountResponse, GetZonesData, GetZonesResponse, HeartbeatData, HighlightPreviewData, HighlightPreviewResponse, IssueDirectData, IssueDirectResponse, IssueImagePresignedUrlData, IssueImagePresignedUrlResponse, IssuePresignedUrlData, IssuePresignedUrlResponse, IssueProfileImagePresignedUrlData, IssueProfileImagePresignedUrlResponse, LoginData, LoginResponse, LogoutData, MarkAllReadData, MarkReadData, OauthCodeLoginData, OauthCodeLoginResponse, OauthLoginData, OauthLoginResponse, PreviewData, PreviewResponse, Probe1Data, Probe1Response, ProbeData, ProbeResponse, RecommendData, RecommendResponse, RedirectToKakaoAuthorizeData, RegisterData, ReissueData, ReissueResponse, Reject1Data, Reject1Response, Reject2Data, Reject2Response, Reject3Data, RejectData, RejectEmailChangeData, RemoveHelpfulData, RemoveHelpfulResponse, RemoveProfileImageData, RemoveProfileImageResponse, ReplaceData, ReplaceFeaturedData, ReplaceFeaturedResponse, ReplaceResponse, ReportData, ReportResponse, RequestData, RequestEmailChangeData, RequestResetData, RequestResponse, ResendPasswordData, ResendPasswordResponse, ResetPasswordData, ResubmitData, ResubmitResponse, ReverseGeocodeData, ReverseGeocodeResponse, SearchPlacesData, SearchPlacesResponse, SetInitialPasswordData, SetVisibilityData, SetVisibilityResponse, SignupData, SignupResponse, SocialLoginData, SocialLoginResponse, SubmitConsentsData, SubmitConsentsResponse, SubmitData, SubmitResponse, UnblindVideoData, UnblindVideoResponse, UnblockData, UnpublishData, UnpublishResponse, UnregisterData, UpdateCommentData, UpdateCommentResponse, UpdateData, UpdateLocationConsentData, UpdateLocationConsentResponse, UpdateMarketingConsentData, UpdateMarketingConsentResponse, UpdateNicknameData, UpdateNicknameResponse, UpdateProfileData, UpdateProfileImageData, UpdateProfileImageResponse, UpdateProfileResponse, UpdateResponse, UpdateSubscriptionData, UpdateSubscriptionResponse, Upload1Data, Upload1Response, UploadData, UploadMissionVideoData, UploadMissionVideoResponse, UploadResponse, WalkPathsData, WalkPathsResponse, WhoamiData, WhoamiResponse } from '../types.gen';
/**
* 영상 삭제
@@ -401,6 +401,44 @@ export const highlightPreviewMutation = (options?: Partial>): UseMutationOptions> => {
+ const mutationOptions: UseMutationOptions> = {
+ mutationFn: async (fnOptions) => {
+ const { data } = await unblock({
+ ...options,
+ ...fnOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
+/**
+ * 사용자 차단
+ *
+ * 경로의 사용자를 차단한다. 차단하면 두 사람 사이의 친구 관계(수락됨·대기 중, 방향 무관)가 함께 삭제되고, 이후 서로의 영상과 댓글이 목록·재생·상세에서 보이지 않는다. 이미 차단한 사용자를 다시 차단해도 성공하며 최초 차단 시각이 유지된다(멱등). 자기 자신은 400 + 1430, 존재하지 않는 사용자는 404 + 1404 다. 신고는 차단과 무관하게 계속 할 수 있다.
+ */
+export const blockMutation = (options?: Partial>): UseMutationOptions> => {
+ const mutationOptions: UseMutationOptions> = {
+ mutationFn: async (fnOptions) => {
+ const { data } = await block({
+ ...options,
+ ...fnOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
/**
* 프로필 이미지 업로드용 presigned URL 발급
*
@@ -595,7 +633,7 @@ export const getMissionVideosQueryKey = (options: Options)
/**
* 미션 영상 목록 조회
*
- * 그 미션의 대상 격자에서 미션 기간에 촬영된 공개(PUBLIC)·READY 영상을 촬영 시각(recordedAt) 최신순으로 페이지 조회한다 — 촬영 시각이 같으면 videoId 내림차순으로 갈린다. 기간이 없는 미션(코스·지속형)은 기간 조건 없이 과거 영상까지 담고, 기간이 끝난 미션도 목록은 그대로 조회된다. 비공개·친구 공개·삭제·블라인드·인코딩 미완 영상은 본인 것이라도 제외되며, 응답은 누가 부르든 같다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 커서는 발급된 그 미션 전용이라 다른 미션 커서는 400(INVALID_CURSOR)이고, 형식이 깨진 커서도 같다. size 는 1~50 밖이면 클램프된다. 조건에 맞는 영상이 없거나 존재하지 않는 missionId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.
+ * 그 미션의 대상 격자에서 미션 기간에 촬영된 공개(PUBLIC)·READY 영상을 촬영 시각(recordedAt) 최신순으로 페이지 조회한다 — 촬영 시각이 같으면 videoId 내림차순으로 갈린다. 기간이 없는 미션(코스·지속형)은 기간 조건 없이 과거 영상까지 담고, 기간이 끝난 미션도 목록은 그대로 조회된다. 비공개·친구 공개·삭제·블라인드·인코딩 미완 영상은 본인 것이라도 제외된다. 로그인 요청이면 요청자와 차단 관계(어느 방향이든)인 작성자의 영상도 빠지고, 그 밖에는 응답이 누가 부르든 같다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 커서는 발급된 그 미션 전용이라 다른 미션 커서는 400(INVALID_CURSOR)이고, 형식이 깨진 커서도 같다. size 는 1~50 밖이면 클램프된다. 조건에 맞는 영상이 없거나 존재하지 않는 missionId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.
*/
export const getMissionVideosOptions = (options: Options) => queryOptions>({
queryFn: async ({ queryKey, signal }) => {
@@ -644,7 +682,7 @@ export const getMissionVideosInfiniteQueryKey = (options: Options) => {
const opts = infiniteQueryOptions, QueryKey>, string | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>(
@@ -764,7 +802,7 @@ export const getCommentsQueryKey = (options: Options) => create
*
* 영상 상세가 첫 페이지(20건)를 이미 품고 있으므로 이 API 는 둘째 페이지부터를 위한 것이다. cursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 영상 목록에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.
*
- * 아카이브된 행사에서도 조회할 수 있고 댓글이 없으면 실패가 아니라 빈 페이지다. 비로그인으로도 조회할 수 있다.
+ * 아카이브된 행사에서도 조회할 수 있고 댓글이 없으면 실패가 아니라 빈 페이지다. 비로그인으로도 조회할 수 있다. 로그인 요청이면 영상 작성자와 차단 관계(어느 방향이든)일 때 상세와 같은 404 + 13406 이고, 차단 관계인 작성자의 댓글은 목록에서 빠진다(댓글 수는 그대로다).
*/
export const getCommentsOptions = (options: Options) => queryOptions>({
queryFn: async ({ queryKey, signal }) => {
@@ -788,7 +826,7 @@ export const getCommentsInfiniteQueryKey = (options: Options):
*
* 영상 상세가 첫 페이지(20건)를 이미 품고 있으므로 이 API 는 둘째 페이지부터를 위한 것이다. cursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 영상 목록에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.
*
- * 아카이브된 행사에서도 조회할 수 있고 댓글이 없으면 실패가 아니라 빈 페이지다. 비로그인으로도 조회할 수 있다.
+ * 아카이브된 행사에서도 조회할 수 있고 댓글이 없으면 실패가 아니라 빈 페이지다. 비로그인으로도 조회할 수 있다. 로그인 요청이면 영상 작성자와 차단 관계(어느 방향이든)일 때 상세와 같은 404 + 13406 이고, 차단 관계인 작성자의 댓글은 목록에서 빠진다(댓글 수는 그대로다).
*/
export const getCommentsInfiniteOptions = (options: Options) => {
const opts = infiniteQueryOptions, QueryKey>, string | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>(
@@ -849,7 +887,7 @@ export const getLocationVideosQueryKey = (options: Options) => queryOptions>({
queryFn: async ({ queryKey, signal }) => {
@@ -875,7 +913,7 @@ export const getLocationVideosInfiniteQueryKey = (options: Options) => {
const opts = infiniteQueryOptions, QueryKey>, string | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>(
@@ -1073,7 +1111,7 @@ export const changePasswordMutation = (options?: Partial>): UseMutationOptions> => {
const mutationOptions: UseMutationOptions> = {
@@ -1327,12 +1365,12 @@ export const resendPasswordMutation = (options?: Partial메일은 발송되지 않는다 — 반려 통보는 당분간 수기이고 저장된 사유가 그 재료다.
+ * 요청을 반려하고 사유를 저장한 뒤, 요청자의 공식 이메일로 반려 사유를 담은 안내 메일을 발송한다 (필맵 서식 HTML + 평문 대체본). 사유는 필수다. 발송 실패는 반려를 뒤집지 않고 emailSent:false 로만 드러나며, 그때는 저장된 사유로 수기 통보한다(재발송 API 없음).
*
* 없는 요청은 404(1421), 이미 처리된 요청은 409(1422), 검토 이후 요청 내용이 바뀌었으면 409(1426) 다.
*/
-export const reject2Mutation = (options?: Partial>): UseMutationOptions> => {
- const mutationOptions: UseMutationOptions> = {
+export const reject2Mutation = (options?: Partial>): UseMutationOptions> => {
+ const mutationOptions: UseMutationOptions> = {
mutationFn: async (fnOptions) => {
const { data } = await reject2({
...options,
@@ -1689,6 +1727,21 @@ export const updateCommentMutation = (options?: Partial) => createQueryKey('whoami', options);
+
+export const whoamiOptions = (options?: Options) => queryOptions>({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await whoami({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: whoamiQueryKey(options)
+});
+
export const getZonesQueryKey = (options?: Options) => createQueryKey('getZones', options);
/**
@@ -1748,6 +1801,26 @@ export const getMeOptions = (options?: Options) => queryOptions) => createQueryKey('getBlockedUsers', options);
+
+/**
+ * 내가 차단한 사용자 목록
+ *
+ * 내가 차단한 사용자 전부를 차단 시각 내림차순으로 페이지 없이 반환한다. 닉네임·프로필 이미지는 조회 시점 값이다. 나를 차단한 사용자는 포함되지 않고, 차단이 없으면 빈 배열이다.
+ */
+export const getBlockedUsersOptions = (options?: Options) => queryOptions>({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await getBlockedUsers({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: getBlockedUsersQueryKey(options)
+});
+
export const getTrendingKeywordsQueryKey = (options?: Options) => createQueryKey('getTrendingKeywords', options);
/**
@@ -2026,6 +2099,21 @@ export const getMySubmissionsOptions = (options?: Options)
queryKey: getMySubmissionsQueryKey(options)
});
+export const probeQueryKey = (options?: Options) => createQueryKey('probe', options);
+
+export const probeOptions = (options?: Options) => queryOptions>({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await probe({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: probeQueryKey(options)
+});
+
export const getInboxQueryKey = (options?: Options) => createQueryKey('getInbox', options);
/**
@@ -2341,7 +2429,7 @@ export const getGridGlobalVideosQueryKey = (options: Options) => queryOptions>({
queryFn: async ({ queryKey, signal }) => {
@@ -2361,7 +2449,7 @@ export const getGridGlobalVideosInfiniteQueryKey = (options: Options) => {
const opts = infiniteQueryOptions, QueryKey>, string | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>(
@@ -2727,7 +2815,7 @@ export const getVideoDetailQueryKey = (options: Options) =>
*
* 영상 하나의 재생본 presigned GET URL 과 표시 재료를 돌려준다. 소속 행사 회차·위치·대표 격자와 그 표시명 재료가 함께 담겨, 상세 화면이 추가 호출 없이 위치줄을 그린다.
*
- * 피드에 보이는 영상만 열린다 — 삭제·블라인드·비공개·처리 미완료 영상은 올린 본인에게도 404 + developCode 13406 이다(본인 영상 확인은 GET /api/videos/{videoId}). 행사 영상이 아닌 영상 id 도 같은 404 다.
+ * 피드에 보이는 영상만 열린다 — 삭제·블라인드·비공개·처리 미완료 영상은 올린 본인에게도 404 + developCode 13406 이다(본인 영상 확인은 GET /api/videos/{videoId}). 행사 영상이 아닌 영상 id 도 같은 404 이고, 작성자와 차단 관계(어느 방향이든)인 요청자에게도 같은 404 다.
*
* interactionLocked 는 아카이브 전환(행사 종료 + 30일)부터 true 이며 댓글·도움돼요 입력 UI 를 비활성화하는 재료다(기존 수는 계속 표시. 유예 기간에는 반응을 계속 남길 수 있다). 재생 URL 을 발급받은 타인 조회는 조회수를 올린다 — 비로그인 조회도 포함이고 올린 본인은 제외다.
*/
@@ -2749,7 +2837,7 @@ export const getOccurrencesInViewportQueryKey = (options: Options) => quer
queryKey: findMyBadgesQueryKey(options)
});
+export const probe1QueryKey = (options?: Options) => createQueryKey('probe1', options);
+
+export const probe1Options = (options?: Options) => queryOptions>({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await probe1({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: probe1QueryKey(options)
+});
+
export const getStatusQueryKey = (options?: Options) => createQueryKey('getStatus', options);
/**
diff --git a/apps/web/src/shared/api/generated/index.ts b/apps/web/src/shared/api/generated/index.ts
index eefbe625..31d3a849 100644
--- a/apps/web/src/shared/api/generated/index.ts
+++ b/apps/web/src/shared/api/generated/index.ts
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
-export { accept, addHelpful, approve, approve1, approve2, approveEmailChange, changePassword, create, createComment, delete_, deleteComment, deleteFriend, deleteMe, findMyBadges, getAccounts, getActiveMissionsInViewport, getApprovedEvents, getCell, getCollectionGrids, getComments, getConsentStatus, getDistricts, getEmailChangeRequests, getEventLocationsByGrid, getEvents, getExploreRegions, getFriendGridAggregates, getFriendGrids, getFriendGridVideos, getFriendProfile, getFriends, getGridCover, getGridGlobalVideos, getGridHourlyUploads, getGridVideos, getHotZoneAggregates, getHotZones, getInbox, getLocations, getLocationVideos, getMe, getMissionAggregates, getMissionDetail, getMissionsByGrid, getMissionVideos, getMyFriendCode, getMyProgress, getMySubmissions, getNationalStat, getOccupiedAggregatesInViewport, getOccupiedInViewport, getOccurrenceDetail, getOccurrencesInViewport, getPlayback, getPreferences, getProfile, getReceivedRequests, getRegionGrids, getRegionVideos, getReports, getRequest, getRequests, getStatByGrid, getStatByPoint, getStats, getStatus, getSubmission, getSubmission1, getSubmissions, getSummary, getTrendingKeywords, getUnreadCount, getUploadHistory, getVideoDetail, getVideoForReview, getViewerCount, getZones, heartbeat, highlightPreview, issueDirect, issueImagePresignedUrl, issuePresignedUrl, issueProfileImagePresignedUrl, login, logout, markAllRead, markRead, oauthCodeLogin, oauthLogin, type Options, preview, recommend, redirectToKakaoAuthorize, register, reissue, reject, reject1, reject2, reject3, rejectEmailChange, removeHelpful, removeProfileImage, replace, replaceFeatured, report, request, requestEmailChange, requestReset, resendPassword, resetPassword, resubmit, reverseGeocode, searchPlaces, setInitialPassword, setVisibility, signup, socialLogin, submit, submitConsents, unblindVideo, unpublish, unregister, update, updateComment, updateLocationConsent, updateMarketingConsent, updateNickname, updateProfile, updateProfileImage, updateSubscription, upload, upload1, uploadMissionVideo, walkPaths } from './sdk.gen';
-export type { AcceptData, AcceptResponses, AddHelpfulData, AddHelpfulResponse, AddHelpfulResponses, AdminApprovedEventItemResponseDto, AdminApprovedEventListResponseDto, AdminEmailChangeRequestItemResponseDto, AdminEmailChangeRequestListResponseDto, AdminEventSubmissionDetailResponseDto, AdminEventSubmissionItemResponseDto, AdminEventSubmissionListResponseDto, AdminEventUnpublishRequestDto, AdminEventUnpublishResponseDto, AdminOrgAccountItemResponseDto, AdminOrgAccountListResponseDto, AdminOrgAccountRequestDetailResponseDto, AdminOrgAccountRequestItemResponseDto, AdminOrgAccountRequestListResponseDto, AdminReportItemResponseDto, AdminReportListResponseDto, AdminReportProcessResponseDto, AdminVideoReviewResponseDto, AdminVideoUnblindResponseDto, ApiResponseDtoAdminApprovedEventListResponseDto, ApiResponseDtoAdminEmailChangeRequestListResponseDto, ApiResponseDtoAdminEventSubmissionDetailResponseDto, ApiResponseDtoAdminEventSubmissionListResponseDto, ApiResponseDtoAdminEventUnpublishResponseDto, ApiResponseDtoAdminOrgAccountListResponseDto, ApiResponseDtoAdminOrgAccountRequestDetailResponseDto, ApiResponseDtoAdminOrgAccountRequestListResponseDto, ApiResponseDtoAdminReportListResponseDto, ApiResponseDtoAdminReportProcessResponseDto, ApiResponseDtoAdminVideoReviewResponseDto, ApiResponseDtoAdminVideoUnblindResponseDto, ApiResponseDtoCollectionSummaryResponseDto, ApiResponseDtoConsentStatusResponseDto, ApiResponseDtoEmailChangeApproveResponseDto, ApiResponseDtoEventLocationVideoPageResponseDto, ApiResponseDtoEventNotificationResponseDto, ApiResponseDtoEventOccurrenceDetailResponseDto, ApiResponseDtoEventSubmissionApproveResponseDto, ApiResponseDtoEventSubmissionDetailResponseDto, ApiResponseDtoEventSubmissionImagePresignResponseDto, ApiResponseDtoEventSubmissionMyListResponseDto, ApiResponseDtoEventSubmissionSubmitResponseDto, ApiResponseDtoEventVideoCommentPageResponseDto, ApiResponseDtoEventVideoCommentResponseDto, ApiResponseDtoEventVideoDetailResponseDto, ApiResponseDtoEventVideoHelpfulResponseDto, ApiResponseDtoEventVideoUploadResponseDto, ApiResponseDtoEventViewerCountResponseDto, ApiResponseDtoFriendCodeResponseDto, ApiResponseDtoFriendPreviewResponseDto, ApiResponseDtoFriendProfileResponseDto, ApiResponseDtoFriendRequestCreateResponseDto, ApiResponseDtoGridAggregationResponseDto, ApiResponseDtoGridCellResponseDto, ApiResponseDtoGridCoverVideoResponseDto, ApiResponseDtoGridHourlyUploadResponseDto, ApiResponseDtoGridVideoPageResponseDto, ApiResponseDtoHighlightPreviewResponseDto, ApiResponseDtoHotZoneListResponseDto, ApiResponseDtoListCollectionGridResponseDto, ApiResponseDtoListEventLocationResponseDto, ApiResponseDtoListEventOccurrenceChipResponseDto, ApiResponseDtoListFeaturedBadgeResponseDto, ApiResponseDtoListFriendGridVideoResponseDto, ApiResponseDtoListFriendListItemResponseDto, ApiResponseDtoListGridEventLocationResponseDto, ApiResponseDtoListGridMissionResponseDto, ApiResponseDtoListGridVideoResponseDto, ApiResponseDtoListHotZoneRegionAggregateResponseDto, ApiResponseDtoListMissionProgressResponseDto, ApiResponseDtoListMissionRegionAggregateResponseDto, ApiResponseDtoListMissionResponseDto, ApiResponseDtoListMyBadgeResponseDto, ApiResponseDtoListPlaceSearchResponseDto, ApiResponseDtoListReceivedFriendRequestResponseDto, ApiResponseDtoListRegionAggregateResponseDto, ApiResponseDtoListRegionDistrictResponseDto, ApiResponseDtoListRegionStatResponseDto, ApiResponseDtoListRegionVideoResponseDto, ApiResponseDtoListTrendingKeywordResponseDto, ApiResponseDtoListUploadHistoryResponseDto, ApiResponseDtoListZoneResponseDto, ApiResponseDtoLoginResponseDto, ApiResponseDtoMissionDetailResponseDto, ApiResponseDtoMissionVideoUploadResponseDto, ApiResponseDtoNotificationPageResponseDto, ApiResponseDtoNotificationPreferenceResponseDto, ApiResponseDtoNotificationUnreadCountResponseDto, ApiResponseDtoOccupiedGridPageResponseDto, ApiResponseDtoOrgAccountIssueResponseDto, ApiResponseDtoOrgAccountResendResponseDto, ApiResponseDtoOrgEventListResponseDto, ApiResponseDtoOrgProfileResponseDto, ApiResponseDtoPasswordStatusResponseDto, ApiResponseDtoPresignedUrlResponseDto, ApiResponseDtoProfileImagePresignResponseDto, ApiResponseDtoRegionExplorePageResponseDto, ApiResponseDtoRegionExploreResponseDto, ApiResponseDtoRegionNationalStatResponseDto, ApiResponseDtoRegionResponseDto, ApiResponseDtoRegionStatResponseDto, ApiResponseDtoReissueResponseDto, ApiResponseDtoReportCreateResponseDto, ApiResponseDtoRouteRecommendResponseDto, ApiResponseDtoRouteWalkPathResponseDto, ApiResponseDtoSignupResponseDto, ApiResponseDtoUserProfileResponseDto, ApiResponseDtoVideoPlaybackResponseDto, ApiResponseDtoVideoReplaceResponseDto, ApiResponseDtoVideoUploadResponseDto, ApiResponseDtoVideoVisibilityResponseDto, Approve1Data, Approve1Response, Approve1Responses, Approve2Data, Approve2Response, Approve2Responses, ApproveData, ApproveEmailChangeData, ApproveEmailChangeResponse, ApproveEmailChangeResponses, ApproveResponse, ApproveResponses, BoxShape, CategoryPreferenceDto, Cell, CellsShape, ChangePasswordData, ChangePasswordResponses, ClientOptions, CollectionGridResponseDto, CollectionSummaryResponseDto, CompletedMissionResponseDto, ConsentStatusResponseDto, ConsentSubmitRequestDto, CreateCommentData, CreateCommentResponse, CreateCommentResponses, CreateData, CreateResponses, CurrentRegionResponseDto, DeleteCommentData, DeleteCommentResponses, DeleteData, DeleteFriendData, DeleteFriendResponses, DeleteMeData, DeleteMeResponses, DeleteResponses, DevSocialLoginRequestDto, EarnedBadgeResponseDto, EmailChangeApproveRequestDto, EmailChangeApproveResponseDto, EmailChangeRejectRequestDto, EventLocationResponseDto, EventLocationVideoPageResponseDto, EventLocationVideoResponseDto, EventNotificationResponseDto, EventNotificationUpdateRequestDto, EventOccurrenceChipResponseDto, EventOccurrenceDetailResponseDto, EventSubmissionApproveResponseDto, EventSubmissionAreaRectDto, EventSubmissionCreateRequestDto, EventSubmissionDetailResponseDto, EventSubmissionHistoryResponseDto, EventSubmissionImagePresignRequestDto, EventSubmissionImagePresignResponseDto, EventSubmissionLocationRequestDto, EventSubmissionLocationResponseDto, EventSubmissionMyListResponseDto, EventSubmissionParentEventResponseDto, EventSubmissionRejectionResponseDto, EventSubmissionRejectRequestDto, EventSubmissionStatusCountsResponseDto, EventSubmissionSubmitResponseDto, EventSubmissionSummaryResponseDto, EventSubmissionUpdateRequestDto, EventVideoCommentPageResponseDto, EventVideoCommentRequestDto, EventVideoCommentResponseDto, EventVideoDetailResponseDto, EventVideoHelpfulResponseDto, EventVideoUploadRequestDto, EventVideoUploadResponseDto, EventViewerCountResponseDto, ExploreGridResponseDto, FeaturedBadgeRequestDto, FeaturedBadgeResponseDto, FindMyBadgesData, FindMyBadgesResponse, FindMyBadgesResponses, FriendCodeResponseDto, FriendCollectionGridResponseDto, FriendGridVideoResponseDto, FriendListItemResponseDto, FriendPreviewResponseDto, FriendProfileResponseDto, FriendRequestCreateRequestDto, FriendRequestCreateResponseDto, GetAccountsData, GetAccountsResponse, GetAccountsResponses, GetActiveMissionsInViewportData, GetActiveMissionsInViewportResponse, GetActiveMissionsInViewportResponses, GetApprovedEventsData, GetApprovedEventsResponse, GetApprovedEventsResponses, GetCellData, GetCellResponse, GetCellResponses, GetCollectionGridsData, GetCollectionGridsResponse, GetCollectionGridsResponses, GetCommentsData, GetCommentsResponse, GetCommentsResponses, GetConsentStatusData, GetConsentStatusResponse, GetConsentStatusResponses, GetDistrictsData, GetDistrictsResponse, GetDistrictsResponses, GetEmailChangeRequestsData, GetEmailChangeRequestsResponse, GetEmailChangeRequestsResponses, GetEventLocationsByGridData, GetEventLocationsByGridResponse, GetEventLocationsByGridResponses, GetEventsData, GetEventsResponse, GetEventsResponses, GetExploreRegionsData, GetExploreRegionsResponse, GetExploreRegionsResponses, GetFriendGridAggregatesData, GetFriendGridAggregatesResponse, GetFriendGridAggregatesResponses, GetFriendGridsData, GetFriendGridsResponse, GetFriendGridsResponses, GetFriendGridVideosData, GetFriendGridVideosResponse, GetFriendGridVideosResponses, GetFriendProfileData, GetFriendProfileResponse, GetFriendProfileResponses, GetFriendsData, GetFriendsResponse, GetFriendsResponses, GetGridCoverData, GetGridCoverResponse, GetGridCoverResponses, GetGridGlobalVideosData, GetGridGlobalVideosResponse, GetGridGlobalVideosResponses, GetGridHourlyUploadsData, GetGridHourlyUploadsResponse, GetGridHourlyUploadsResponses, GetGridVideosData, GetGridVideosResponse, GetGridVideosResponses, GetHotZoneAggregatesData, GetHotZoneAggregatesResponse, GetHotZoneAggregatesResponses, GetHotZonesData, GetHotZonesResponse, GetHotZonesResponses, GetInboxData, GetInboxResponse, GetInboxResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetLocationVideosData, GetLocationVideosResponse, GetLocationVideosResponses, GetMeData, GetMeResponse, GetMeResponses, GetMissionAggregatesData, GetMissionAggregatesResponse, GetMissionAggregatesResponses, GetMissionDetailData, GetMissionDetailResponse, GetMissionDetailResponses, GetMissionsByGridData, GetMissionsByGridResponse, GetMissionsByGridResponses, GetMissionVideosData, GetMissionVideosResponse, GetMissionVideosResponses, GetMyFriendCodeData, GetMyFriendCodeResponse, GetMyFriendCodeResponses, GetMyProgressData, GetMyProgressResponse, GetMyProgressResponses, GetMySubmissionsData, GetMySubmissionsResponse, GetMySubmissionsResponses, GetNationalStatData, GetNationalStatResponse, GetNationalStatResponses, GetOccupiedAggregatesInViewportData, GetOccupiedAggregatesInViewportResponse, GetOccupiedAggregatesInViewportResponses, GetOccupiedInViewportData, GetOccupiedInViewportResponse, GetOccupiedInViewportResponses, GetOccurrenceDetailData, GetOccurrenceDetailResponse, GetOccurrenceDetailResponses, GetOccurrencesInViewportData, GetOccurrencesInViewportResponse, GetOccurrencesInViewportResponses, GetPlaybackData, GetPlaybackResponse, GetPlaybackResponses, GetPreferencesData, GetPreferencesResponse, GetPreferencesResponses, GetProfileData, GetProfileResponse, GetProfileResponses, GetReceivedRequestsData, GetReceivedRequestsResponse, GetReceivedRequestsResponses, GetRegionGridsData, GetRegionGridsResponse, GetRegionGridsResponses, GetRegionVideosData, GetRegionVideosResponse, GetRegionVideosResponses, GetReportsData, GetReportsResponse, GetReportsResponses, GetRequestData, GetRequestResponse, GetRequestResponses, GetRequestsData, GetRequestsResponse, GetRequestsResponses, GetStatByGridData, GetStatByGridResponse, GetStatByGridResponses, GetStatByPointData, GetStatByPointResponse, GetStatByPointResponses, GetStatsData, GetStatsResponse, GetStatsResponses, GetStatusData, GetStatusResponse, GetStatusResponses, GetSubmission1Data, GetSubmission1Response, GetSubmission1Responses, GetSubmissionData, GetSubmissionResponse, GetSubmissionResponses, GetSubmissionsData, GetSubmissionsResponse, GetSubmissionsResponses, GetSummaryData, GetSummaryResponse, GetSummaryResponses, GetTrendingKeywordsData, GetTrendingKeywordsResponse, GetTrendingKeywordsResponses, GetUnreadCountData, GetUnreadCountResponse, GetUnreadCountResponses, GetUploadHistoryData, GetUploadHistoryResponse, GetUploadHistoryResponses, GetVideoDetailData, GetVideoDetailResponse, GetVideoDetailResponses, GetVideoForReviewData, GetVideoForReviewResponse, GetVideoForReviewResponses, GetViewerCountData, GetViewerCountResponse, GetViewerCountResponses, GetZonesData, GetZonesResponse, GetZonesResponses, GridAggregationResponseDto, GridCellResponseDto, GridCoverVideoResponseDto, GridEventLocationResponseDto, GridGlobalVideoResponseDto, GridHourlyUploadResponseDto, GridMissionResponseDto, GridVideoPageResponseDto, GridVideoResponseDto, HeartbeatData, HeartbeatResponses, HighlightPreviewData, HighlightPreviewRequestDto, HighlightPreviewResponse, HighlightPreviewResponseDto, HighlightPreviewResponses, HotZoneListResponseDto, HotZoneRegionAggregateResponseDto, HotZoneResponseDto, HourlyUploadCountResponseDto, IssueDirectData, IssueDirectResponse, IssueDirectResponses, IssueImagePresignedUrlData, IssueImagePresignedUrlResponse, IssueImagePresignedUrlResponses, IssuePresignedUrlData, IssuePresignedUrlResponse, IssuePresignedUrlResponses, IssueProfileImagePresignedUrlData, IssueProfileImagePresignedUrlResponse, IssueProfileImagePresignedUrlResponses, KakaoCodeLoginRequestDto, LatLng, LocationConsentUpdateRequestDto, LoginData, LoginRequestDto, LoginResponse, LoginResponseDto, LoginResponses, LogoutData, LogoutRequestDto, LogoutResponses, MarkAllReadData, MarkAllReadResponses, MarketingConsentUpdateRequestDto, MarkReadData, MarkReadResponses, MentionedAreaDto, MissionDetailResponseDto, MissionProgressResponseDto, MissionRegionAggregateResponseDto, MissionResponseDto, MissionShape, MissionVideoUploadRequestDto, MissionVideoUploadResponseDto, MyBadgeResponseDto, NicknameUpdateRequestDto, NotificationItemResponseDto, NotificationPageResponseDto, NotificationPreferenceResponseDto, NotificationPreferenceUpdateRequestDto, NotificationUnreadCountResponseDto, OauthCodeLoginData, OauthCodeLoginResponse, OauthCodeLoginResponses, OauthLoginData, OauthLoginResponse, OauthLoginResponses, OccupiedGridPageResponseDto, OccupiedGridResponseDto, OidcLoginRequestDto, OrgAccountCreateRequestDto, OrgAccountIssueResponseDto, OrgAccountRequestApproveRequestDto, OrgAccountRequestCreateRequestDto, OrgAccountRequestRejectRequestDto, OrgAccountResendResponseDto, OrgEmailChangeRequestDto, OrgEventCityCountResponseDto, OrgEventItemResponseDto, OrgEventListResponseDto, OrgProfileResponseDto, OrgProfileUpdateRequestDto, OriginDto, PasswordChangeRequestDto, PasswordInitialRequestDto, PasswordResetConfirmRequestDto, PasswordResetRequestDto, PasswordStatusResponseDto, PathPointDto, PathShape, PlaceSearchResponseDto, PresignedUrlRequestDto, PresignedUrlResponseDto, PreviewData, PreviewResponse, PreviewResponses, PreviousOccurrenceDto, ProfileImagePresignRequestDto, ProfileImagePresignResponseDto, ProfileImageUpdateRequestDto, PushTokenRequestDto, ReceivedFriendRequestResponseDto, RecommendData, RecommendResponse, RecommendResponses, RedirectToKakaoAuthorizeData, RedirectToKakaoAuthorizeResponses, RegionAggregateResponseDto, RegionDistrictResponseDto, RegionExplorePageResponseDto, RegionExploreResponseDto, RegionGridCountResponseDto, RegionNationalStatResponseDto, RegionResponseDto, RegionShape, RegionStatResponseDto, RegionVideoResponseDto, RegisterData, RegisterResponses, ReissueData, ReissueRequestDto, ReissueResponse, ReissueResponseDto, ReissueResponses, Reject1Data, Reject1Response, Reject1Responses, Reject2Data, Reject2Responses, Reject3Data, Reject3Responses, RejectData, RejectEmailChangeData, RejectEmailChangeResponses, RejectResponses, RemoveHelpfulData, RemoveHelpfulResponse, RemoveHelpfulResponses, RemoveProfileImageData, RemoveProfileImageResponse, RemoveProfileImageResponses, ReplaceData, ReplaceFeaturedData, ReplaceFeaturedResponse, ReplaceFeaturedResponses, ReplaceResponse, ReplaceResponses, ReportCreateRequestDto, ReportCreateResponseDto, ReportData, ReportResponse, ReportResponses, RequestData, RequestEmailChangeData, RequestEmailChangeResponses, RequestResetData, RequestResetResponses, RequestResponse, RequestResponses, ResendPasswordData, ResendPasswordResponse, ResendPasswordResponses, ResetPasswordData, ResetPasswordResponses, ResubmitData, ResubmitResponse, ResubmitResponses, ReverseGeocodeData, ReverseGeocodeResponse, ReverseGeocodeResponses, RoutePointDto, RouteRecommendRequestDto, RouteRecommendResponseDto, RouteWalkPathRequestDto, RouteWalkPathResponseDto, SearchPlacesData, SearchPlacesResponse, SearchPlacesResponses, SegmentDto, SetInitialPasswordData, SetInitialPasswordResponses, SetVisibilityData, SetVisibilityResponse, SetVisibilityResponses, SignupData, SignupRequestDto, SignupResponse, SignupResponseDto, SignupResponses, SocialLoginData, SocialLoginResponse, SocialLoginResponses, Spot, SpotStats, SubmitConsentsData, SubmitConsentsResponse, SubmitConsentsResponses, SubmitData, SubmitResponse, SubmitResponses, TrendingKeywordResponseDto, UnblindVideoData, UnblindVideoResponse, UnblindVideoResponses, UnpublishData, UnpublishResponse, UnpublishResponses, UnregisterData, UnregisterResponses, UpdateCommentData, UpdateCommentResponse, UpdateCommentResponses, UpdateData, UpdateLocationConsentData, UpdateLocationConsentResponse, UpdateLocationConsentResponses, UpdateMarketingConsentData, UpdateMarketingConsentResponse, UpdateMarketingConsentResponses, UpdateNicknameData, UpdateNicknameResponse, UpdateNicknameResponses, UpdateProfileData, UpdateProfileImageData, UpdateProfileImageResponse, UpdateProfileImageResponses, UpdateProfileResponse, UpdateProfileResponses, UpdateResponse, UpdateResponses, UpdateSubscriptionData, UpdateSubscriptionResponse, UpdateSubscriptionResponses, Upload1Data, Upload1Response, Upload1Responses, UploadData, UploadHistoryResponseDto, UploadMissionVideoData, UploadMissionVideoResponse, UploadMissionVideoResponses, UploadResponse, UploadResponses, UserProfileResponseDto, VideoPlaybackResponseDto, VideoReplaceRequestDto, VideoReplaceResponseDto, VideoUploadRequestDto, VideoUploadResponseDto, VideoVisibilityRequestDto, VideoVisibilityResponseDto, ViewportDto, WalkPathsData, WalkPathsResponse, WalkPathsResponses, WalkSegmentDto, ZoneResponseDto } from './types.gen';
+export { accept, addHelpful, approve, approve1, approve2, approveEmailChange, block, changePassword, create, createComment, delete_, deleteComment, deleteFriend, deleteMe, findMyBadges, getAccounts, getActiveMissionsInViewport, getApprovedEvents, getBlockedUsers, getCell, getCollectionGrids, getComments, getConsentStatus, getDistricts, getEmailChangeRequests, getEventLocationsByGrid, getEvents, getExploreRegions, getFriendGridAggregates, getFriendGrids, getFriendGridVideos, getFriendProfile, getFriends, getGridCover, getGridGlobalVideos, getGridHourlyUploads, getGridVideos, getHotZoneAggregates, getHotZones, getInbox, getLocations, getLocationVideos, getMe, getMissionAggregates, getMissionDetail, getMissionsByGrid, getMissionVideos, getMyFriendCode, getMyProgress, getMySubmissions, getNationalStat, getOccupiedAggregatesInViewport, getOccupiedInViewport, getOccurrenceDetail, getOccurrencesInViewport, getPlayback, getPreferences, getProfile, getReceivedRequests, getRegionGrids, getRegionVideos, getReports, getRequest, getRequests, getStatByGrid, getStatByPoint, getStats, getStatus, getSubmission, getSubmission1, getSubmissions, getSummary, getTrendingKeywords, getUnreadCount, getUploadHistory, getVideoDetail, getVideoForReview, getViewerCount, getZones, heartbeat, highlightPreview, issueDirect, issueImagePresignedUrl, issuePresignedUrl, issueProfileImagePresignedUrl, login, logout, markAllRead, markRead, oauthCodeLogin, oauthLogin, type Options, preview, probe, probe1, recommend, redirectToKakaoAuthorize, register, reissue, reject, reject1, reject2, reject3, rejectEmailChange, removeHelpful, removeProfileImage, replace, replaceFeatured, report, request, requestEmailChange, requestReset, resendPassword, resetPassword, resubmit, reverseGeocode, searchPlaces, setInitialPassword, setVisibility, signup, socialLogin, submit, submitConsents, unblindVideo, unblock, unpublish, unregister, update, updateComment, updateLocationConsent, updateMarketingConsent, updateNickname, updateProfile, updateProfileImage, updateSubscription, upload, upload1, uploadMissionVideo, walkPaths, whoami } from './sdk.gen';
+export type { AcceptData, AcceptResponses, AddHelpfulData, AddHelpfulResponse, AddHelpfulResponses, AdminApprovedEventItemResponseDto, AdminApprovedEventListResponseDto, AdminEmailChangeRequestItemResponseDto, AdminEmailChangeRequestListResponseDto, AdminEventSubmissionDetailResponseDto, AdminEventSubmissionItemResponseDto, AdminEventSubmissionListResponseDto, AdminEventUnpublishRequestDto, AdminEventUnpublishResponseDto, AdminOrgAccountItemResponseDto, AdminOrgAccountListResponseDto, AdminOrgAccountRequestDetailResponseDto, AdminOrgAccountRequestItemResponseDto, AdminOrgAccountRequestListResponseDto, AdminReportItemResponseDto, AdminReportListResponseDto, AdminReportProcessResponseDto, AdminVideoReviewResponseDto, AdminVideoUnblindResponseDto, ApiResponseDtoAdminApprovedEventListResponseDto, ApiResponseDtoAdminEmailChangeRequestListResponseDto, ApiResponseDtoAdminEventSubmissionDetailResponseDto, ApiResponseDtoAdminEventSubmissionListResponseDto, ApiResponseDtoAdminEventUnpublishResponseDto, ApiResponseDtoAdminOrgAccountListResponseDto, ApiResponseDtoAdminOrgAccountRequestDetailResponseDto, ApiResponseDtoAdminOrgAccountRequestListResponseDto, ApiResponseDtoAdminReportListResponseDto, ApiResponseDtoAdminReportProcessResponseDto, ApiResponseDtoAdminVideoReviewResponseDto, ApiResponseDtoAdminVideoUnblindResponseDto, ApiResponseDtoCollectionSummaryResponseDto, ApiResponseDtoConsentStatusResponseDto, ApiResponseDtoEmailChangeApproveResponseDto, ApiResponseDtoEventLocationVideoPageResponseDto, ApiResponseDtoEventNotificationResponseDto, ApiResponseDtoEventOccurrenceDetailResponseDto, ApiResponseDtoEventSubmissionApproveResponseDto, ApiResponseDtoEventSubmissionDetailResponseDto, ApiResponseDtoEventSubmissionImagePresignResponseDto, ApiResponseDtoEventSubmissionMyListResponseDto, ApiResponseDtoEventSubmissionSubmitResponseDto, ApiResponseDtoEventVideoCommentPageResponseDto, ApiResponseDtoEventVideoCommentResponseDto, ApiResponseDtoEventVideoDetailResponseDto, ApiResponseDtoEventVideoHelpfulResponseDto, ApiResponseDtoEventVideoUploadResponseDto, ApiResponseDtoEventViewerCountResponseDto, ApiResponseDtoFriendCodeResponseDto, ApiResponseDtoFriendPreviewResponseDto, ApiResponseDtoFriendProfileResponseDto, ApiResponseDtoFriendRequestCreateResponseDto, ApiResponseDtoGridAggregationResponseDto, ApiResponseDtoGridCellResponseDto, ApiResponseDtoGridCoverVideoResponseDto, ApiResponseDtoGridHourlyUploadResponseDto, ApiResponseDtoGridVideoPageResponseDto, ApiResponseDtoHighlightPreviewResponseDto, ApiResponseDtoHotZoneListResponseDto, ApiResponseDtoListBlockedUserResponseDto, ApiResponseDtoListCollectionGridResponseDto, ApiResponseDtoListEventLocationResponseDto, ApiResponseDtoListEventOccurrenceChipResponseDto, ApiResponseDtoListFeaturedBadgeResponseDto, ApiResponseDtoListFriendGridVideoResponseDto, ApiResponseDtoListFriendListItemResponseDto, ApiResponseDtoListGridEventLocationResponseDto, ApiResponseDtoListGridMissionResponseDto, ApiResponseDtoListGridVideoResponseDto, ApiResponseDtoListHotZoneRegionAggregateResponseDto, ApiResponseDtoListMissionProgressResponseDto, ApiResponseDtoListMissionRegionAggregateResponseDto, ApiResponseDtoListMissionResponseDto, ApiResponseDtoListMyBadgeResponseDto, ApiResponseDtoListPlaceSearchResponseDto, ApiResponseDtoListReceivedFriendRequestResponseDto, ApiResponseDtoListRegionAggregateResponseDto, ApiResponseDtoListRegionDistrictResponseDto, ApiResponseDtoListRegionStatResponseDto, ApiResponseDtoListRegionVideoResponseDto, ApiResponseDtoListTrendingKeywordResponseDto, ApiResponseDtoListUploadHistoryResponseDto, ApiResponseDtoListZoneResponseDto, ApiResponseDtoLoginResponseDto, ApiResponseDtoMissionDetailResponseDto, ApiResponseDtoMissionVideoUploadResponseDto, ApiResponseDtoNotificationPageResponseDto, ApiResponseDtoNotificationPreferenceResponseDto, ApiResponseDtoNotificationUnreadCountResponseDto, ApiResponseDtoOccupiedGridPageResponseDto, ApiResponseDtoOrgAccountIssueResponseDto, ApiResponseDtoOrgAccountRequestRejectResponseDto, ApiResponseDtoOrgAccountResendResponseDto, ApiResponseDtoOrgEventListResponseDto, ApiResponseDtoOrgProfileResponseDto, ApiResponseDtoPasswordStatusResponseDto, ApiResponseDtoPresignedUrlResponseDto, ApiResponseDtoProfileImagePresignResponseDto, ApiResponseDtoRegionExplorePageResponseDto, ApiResponseDtoRegionExploreResponseDto, ApiResponseDtoRegionNationalStatResponseDto, ApiResponseDtoRegionResponseDto, ApiResponseDtoRegionStatResponseDto, ApiResponseDtoReissueResponseDto, ApiResponseDtoReportCreateResponseDto, ApiResponseDtoRouteRecommendResponseDto, ApiResponseDtoRouteWalkPathResponseDto, ApiResponseDtoSignupResponseDto, ApiResponseDtoUserProfileResponseDto, ApiResponseDtoVideoPlaybackResponseDto, ApiResponseDtoVideoReplaceResponseDto, ApiResponseDtoVideoUploadResponseDto, ApiResponseDtoVideoVisibilityResponseDto, Approve1Data, Approve1Response, Approve1Responses, Approve2Data, Approve2Response, Approve2Responses, ApproveData, ApproveEmailChangeData, ApproveEmailChangeResponse, ApproveEmailChangeResponses, ApproveResponse, ApproveResponses, BlockData, BlockedUserResponseDto, BlockResponses, BoxShape, CategoryPreferenceDto, Cell, CellsShape, ChangePasswordData, ChangePasswordResponses, ClientOptions, CollectionGridResponseDto, CollectionSummaryResponseDto, CompletedMissionResponseDto, ConsentStatusResponseDto, ConsentSubmitRequestDto, CreateCommentData, CreateCommentResponse, CreateCommentResponses, CreateData, CreateResponses, CurrentRegionResponseDto, DeleteCommentData, DeleteCommentResponses, DeleteData, DeleteFriendData, DeleteFriendResponses, DeleteMeData, DeleteMeResponses, DeleteResponses, DevSocialLoginRequestDto, EarnedBadgeResponseDto, EmailChangeApproveRequestDto, EmailChangeApproveResponseDto, EmailChangeRejectRequestDto, EventLocationResponseDto, EventLocationVideoPageResponseDto, EventLocationVideoResponseDto, EventNotificationResponseDto, EventNotificationUpdateRequestDto, EventOccurrenceChipResponseDto, EventOccurrenceDetailResponseDto, EventSubmissionApproveResponseDto, EventSubmissionAreaRectDto, EventSubmissionCreateRequestDto, EventSubmissionDetailResponseDto, EventSubmissionHistoryResponseDto, EventSubmissionImagePresignRequestDto, EventSubmissionImagePresignResponseDto, EventSubmissionLocationRequestDto, EventSubmissionLocationResponseDto, EventSubmissionMyListResponseDto, EventSubmissionParentEventResponseDto, EventSubmissionRejectionResponseDto, EventSubmissionRejectRequestDto, EventSubmissionStatusCountsResponseDto, EventSubmissionSubmitResponseDto, EventSubmissionSummaryResponseDto, EventSubmissionUpdateRequestDto, EventVideoCommentPageResponseDto, EventVideoCommentRequestDto, EventVideoCommentResponseDto, EventVideoDetailResponseDto, EventVideoHelpfulResponseDto, EventVideoUploadRequestDto, EventVideoUploadResponseDto, EventViewerCountResponseDto, ExploreGridResponseDto, FeaturedBadgeRequestDto, FeaturedBadgeResponseDto, FindMyBadgesData, FindMyBadgesResponse, FindMyBadgesResponses, FriendCodeResponseDto, FriendCollectionGridResponseDto, FriendGridVideoResponseDto, FriendListItemResponseDto, FriendPreviewResponseDto, FriendProfileResponseDto, FriendRequestCreateRequestDto, FriendRequestCreateResponseDto, GetAccountsData, GetAccountsResponse, GetAccountsResponses, GetActiveMissionsInViewportData, GetActiveMissionsInViewportResponse, GetActiveMissionsInViewportResponses, GetApprovedEventsData, GetApprovedEventsResponse, GetApprovedEventsResponses, GetBlockedUsersData, GetBlockedUsersResponse, GetBlockedUsersResponses, GetCellData, GetCellResponse, GetCellResponses, GetCollectionGridsData, GetCollectionGridsResponse, GetCollectionGridsResponses, GetCommentsData, GetCommentsResponse, GetCommentsResponses, GetConsentStatusData, GetConsentStatusResponse, GetConsentStatusResponses, GetDistrictsData, GetDistrictsResponse, GetDistrictsResponses, GetEmailChangeRequestsData, GetEmailChangeRequestsResponse, GetEmailChangeRequestsResponses, GetEventLocationsByGridData, GetEventLocationsByGridResponse, GetEventLocationsByGridResponses, GetEventsData, GetEventsResponse, GetEventsResponses, GetExploreRegionsData, GetExploreRegionsResponse, GetExploreRegionsResponses, GetFriendGridAggregatesData, GetFriendGridAggregatesResponse, GetFriendGridAggregatesResponses, GetFriendGridsData, GetFriendGridsResponse, GetFriendGridsResponses, GetFriendGridVideosData, GetFriendGridVideosResponse, GetFriendGridVideosResponses, GetFriendProfileData, GetFriendProfileResponse, GetFriendProfileResponses, GetFriendsData, GetFriendsResponse, GetFriendsResponses, GetGridCoverData, GetGridCoverResponse, GetGridCoverResponses, GetGridGlobalVideosData, GetGridGlobalVideosResponse, GetGridGlobalVideosResponses, GetGridHourlyUploadsData, GetGridHourlyUploadsResponse, GetGridHourlyUploadsResponses, GetGridVideosData, GetGridVideosResponse, GetGridVideosResponses, GetHotZoneAggregatesData, GetHotZoneAggregatesResponse, GetHotZoneAggregatesResponses, GetHotZonesData, GetHotZonesResponse, GetHotZonesResponses, GetInboxData, GetInboxResponse, GetInboxResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetLocationVideosData, GetLocationVideosResponse, GetLocationVideosResponses, GetMeData, GetMeResponse, GetMeResponses, GetMissionAggregatesData, GetMissionAggregatesResponse, GetMissionAggregatesResponses, GetMissionDetailData, GetMissionDetailResponse, GetMissionDetailResponses, GetMissionsByGridData, GetMissionsByGridResponse, GetMissionsByGridResponses, GetMissionVideosData, GetMissionVideosResponse, GetMissionVideosResponses, GetMyFriendCodeData, GetMyFriendCodeResponse, GetMyFriendCodeResponses, GetMyProgressData, GetMyProgressResponse, GetMyProgressResponses, GetMySubmissionsData, GetMySubmissionsResponse, GetMySubmissionsResponses, GetNationalStatData, GetNationalStatResponse, GetNationalStatResponses, GetOccupiedAggregatesInViewportData, GetOccupiedAggregatesInViewportResponse, GetOccupiedAggregatesInViewportResponses, GetOccupiedInViewportData, GetOccupiedInViewportResponse, GetOccupiedInViewportResponses, GetOccurrenceDetailData, GetOccurrenceDetailResponse, GetOccurrenceDetailResponses, GetOccurrencesInViewportData, GetOccurrencesInViewportResponse, GetOccurrencesInViewportResponses, GetPlaybackData, GetPlaybackResponse, GetPlaybackResponses, GetPreferencesData, GetPreferencesResponse, GetPreferencesResponses, GetProfileData, GetProfileResponse, GetProfileResponses, GetReceivedRequestsData, GetReceivedRequestsResponse, GetReceivedRequestsResponses, GetRegionGridsData, GetRegionGridsResponse, GetRegionGridsResponses, GetRegionVideosData, GetRegionVideosResponse, GetRegionVideosResponses, GetReportsData, GetReportsResponse, GetReportsResponses, GetRequestData, GetRequestResponse, GetRequestResponses, GetRequestsData, GetRequestsResponse, GetRequestsResponses, GetStatByGridData, GetStatByGridResponse, GetStatByGridResponses, GetStatByPointData, GetStatByPointResponse, GetStatByPointResponses, GetStatsData, GetStatsResponse, GetStatsResponses, GetStatusData, GetStatusResponse, GetStatusResponses, GetSubmission1Data, GetSubmission1Response, GetSubmission1Responses, GetSubmissionData, GetSubmissionResponse, GetSubmissionResponses, GetSubmissionsData, GetSubmissionsResponse, GetSubmissionsResponses, GetSummaryData, GetSummaryResponse, GetSummaryResponses, GetTrendingKeywordsData, GetTrendingKeywordsResponse, GetTrendingKeywordsResponses, GetUnreadCountData, GetUnreadCountResponse, GetUnreadCountResponses, GetUploadHistoryData, GetUploadHistoryResponse, GetUploadHistoryResponses, GetVideoDetailData, GetVideoDetailResponse, GetVideoDetailResponses, GetVideoForReviewData, GetVideoForReviewResponse, GetVideoForReviewResponses, GetViewerCountData, GetViewerCountResponse, GetViewerCountResponses, GetZonesData, GetZonesResponse, GetZonesResponses, GridAggregationResponseDto, GridCellResponseDto, GridCoverVideoResponseDto, GridEventLocationResponseDto, GridGlobalVideoResponseDto, GridHourlyUploadResponseDto, GridMissionResponseDto, GridVideoPageResponseDto, GridVideoResponseDto, HeartbeatData, HeartbeatResponses, HighlightPreviewData, HighlightPreviewRequestDto, HighlightPreviewResponse, HighlightPreviewResponseDto, HighlightPreviewResponses, HotZoneListResponseDto, HotZoneRegionAggregateResponseDto, HotZoneResponseDto, HourlyUploadCountResponseDto, IssueDirectData, IssueDirectResponse, IssueDirectResponses, IssueImagePresignedUrlData, IssueImagePresignedUrlResponse, IssueImagePresignedUrlResponses, IssuePresignedUrlData, IssuePresignedUrlResponse, IssuePresignedUrlResponses, IssueProfileImagePresignedUrlData, IssueProfileImagePresignedUrlResponse, IssueProfileImagePresignedUrlResponses, KakaoCodeLoginRequestDto, LatLng, LocationConsentUpdateRequestDto, LoginData, LoginRequestDto, LoginResponse, LoginResponseDto, LoginResponses, LogoutData, LogoutRequestDto, LogoutResponses, MarkAllReadData, MarkAllReadResponses, MarketingConsentUpdateRequestDto, MarkReadData, MarkReadResponses, MentionedAreaDto, MissionDetailResponseDto, MissionProgressResponseDto, MissionRegionAggregateResponseDto, MissionResponseDto, MissionShape, MissionVideoUploadRequestDto, MissionVideoUploadResponseDto, MyBadgeResponseDto, NicknameUpdateRequestDto, NotificationItemResponseDto, NotificationPageResponseDto, NotificationPreferenceResponseDto, NotificationPreferenceUpdateRequestDto, NotificationUnreadCountResponseDto, OauthCodeLoginData, OauthCodeLoginResponse, OauthCodeLoginResponses, OauthLoginData, OauthLoginResponse, OauthLoginResponses, OccupiedGridPageResponseDto, OccupiedGridResponseDto, OidcLoginRequestDto, OrgAccountCreateRequestDto, OrgAccountIssueResponseDto, OrgAccountRequestApproveRequestDto, OrgAccountRequestCreateRequestDto, OrgAccountRequestRejectRequestDto, OrgAccountRequestRejectResponseDto, OrgAccountResendResponseDto, OrgEmailChangeRequestDto, OrgEventCityCountResponseDto, OrgEventItemResponseDto, OrgEventListResponseDto, OrgProfileResponseDto, OrgProfileUpdateRequestDto, OriginDto, PasswordChangeRequestDto, PasswordInitialRequestDto, PasswordResetConfirmRequestDto, PasswordResetRequestDto, PasswordStatusResponseDto, PathPointDto, PathShape, PlaceSearchResponseDto, PresignedUrlRequestDto, PresignedUrlResponseDto, PreviewData, PreviewResponse, PreviewResponses, PreviousOccurrenceDto, Probe1Data, Probe1Response, Probe1Responses, ProbeData, ProbeResponse, ProbeResponses, ProfileImagePresignRequestDto, ProfileImagePresignResponseDto, ProfileImageUpdateRequestDto, PushTokenRequestDto, ReceivedFriendRequestResponseDto, RecommendData, RecommendResponse, RecommendResponses, RedirectToKakaoAuthorizeData, RedirectToKakaoAuthorizeResponses, RegionAggregateResponseDto, RegionDistrictResponseDto, RegionExplorePageResponseDto, RegionExploreResponseDto, RegionGridCountResponseDto, RegionNationalStatResponseDto, RegionResponseDto, RegionShape, RegionStatResponseDto, RegionVideoResponseDto, RegisterData, RegisterResponses, ReissueData, ReissueRequestDto, ReissueResponse, ReissueResponseDto, ReissueResponses, Reject1Data, Reject1Response, Reject1Responses, Reject2Data, Reject2Response, Reject2Responses, Reject3Data, Reject3Responses, RejectData, RejectEmailChangeData, RejectEmailChangeResponses, RejectResponses, RemoveHelpfulData, RemoveHelpfulResponse, RemoveHelpfulResponses, RemoveProfileImageData, RemoveProfileImageResponse, RemoveProfileImageResponses, ReplaceData, ReplaceFeaturedData, ReplaceFeaturedResponse, ReplaceFeaturedResponses, ReplaceResponse, ReplaceResponses, ReportCreateRequestDto, ReportCreateResponseDto, ReportData, ReportResponse, ReportResponses, RequestData, RequestEmailChangeData, RequestEmailChangeResponses, RequestResetData, RequestResetResponses, RequestResponse, RequestResponses, ResendPasswordData, ResendPasswordResponse, ResendPasswordResponses, ResetPasswordData, ResetPasswordResponses, ResubmitData, ResubmitResponse, ResubmitResponses, ReverseGeocodeData, ReverseGeocodeResponse, ReverseGeocodeResponses, RoutePointDto, RouteRecommendRequestDto, RouteRecommendResponseDto, RouteWalkPathRequestDto, RouteWalkPathResponseDto, SearchPlacesData, SearchPlacesResponse, SearchPlacesResponses, SegmentDto, SetInitialPasswordData, SetInitialPasswordResponses, SetVisibilityData, SetVisibilityResponse, SetVisibilityResponses, SignupData, SignupRequestDto, SignupResponse, SignupResponseDto, SignupResponses, SocialLoginData, SocialLoginResponse, SocialLoginResponses, Spot, SpotStats, SubmitConsentsData, SubmitConsentsResponse, SubmitConsentsResponses, SubmitData, SubmitResponse, SubmitResponses, TrendingKeywordResponseDto, UnblindVideoData, UnblindVideoResponse, UnblindVideoResponses, UnblockData, UnblockResponses, UnpublishData, UnpublishResponse, UnpublishResponses, UnregisterData, UnregisterResponses, UpdateCommentData, UpdateCommentResponse, UpdateCommentResponses, UpdateData, UpdateLocationConsentData, UpdateLocationConsentResponse, UpdateLocationConsentResponses, UpdateMarketingConsentData, UpdateMarketingConsentResponse, UpdateMarketingConsentResponses, UpdateNicknameData, UpdateNicknameResponse, UpdateNicknameResponses, UpdateProfileData, UpdateProfileImageData, UpdateProfileImageResponse, UpdateProfileImageResponses, UpdateProfileResponse, UpdateProfileResponses, UpdateResponse, UpdateResponses, UpdateSubscriptionData, UpdateSubscriptionResponse, UpdateSubscriptionResponses, Upload1Data, Upload1Response, Upload1Responses, UploadData, UploadHistoryResponseDto, UploadMissionVideoData, UploadMissionVideoResponse, UploadMissionVideoResponses, UploadResponse, UploadResponses, UserProfileResponseDto, VideoPlaybackResponseDto, VideoReplaceRequestDto, VideoReplaceResponseDto, VideoUploadRequestDto, VideoUploadResponseDto, VideoVisibilityRequestDto, VideoVisibilityResponseDto, ViewportDto, WalkPathsData, WalkPathsResponse, WalkPathsResponses, WalkSegmentDto, WhoamiData, WhoamiResponse, WhoamiResponses, ZoneResponseDto } from './types.gen';
diff --git a/apps/web/src/shared/api/generated/sdk.gen.ts b/apps/web/src/shared/api/generated/sdk.gen.ts
index 781808ae..b95df2c3 100644
--- a/apps/web/src/shared/api/generated/sdk.gen.ts
+++ b/apps/web/src/shared/api/generated/sdk.gen.ts
@@ -2,7 +2,7 @@
import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client';
import { client } from './client.gen';
-import type { AcceptData, AcceptResponses, AddHelpfulData, AddHelpfulResponses, Approve1Data, Approve1Responses, Approve2Data, Approve2Responses, ApproveData, ApproveEmailChangeData, ApproveEmailChangeResponses, ApproveResponses, ChangePasswordData, ChangePasswordResponses, CreateCommentData, CreateCommentResponses, CreateData, CreateResponses, DeleteCommentData, DeleteCommentResponses, DeleteData, DeleteFriendData, DeleteFriendResponses, DeleteMeData, DeleteMeResponses, DeleteResponses, FindMyBadgesData, FindMyBadgesResponses, GetAccountsData, GetAccountsResponses, GetActiveMissionsInViewportData, GetActiveMissionsInViewportResponses, GetApprovedEventsData, GetApprovedEventsResponses, GetCellData, GetCellResponses, GetCollectionGridsData, GetCollectionGridsResponses, GetCommentsData, GetCommentsResponses, GetConsentStatusData, GetConsentStatusResponses, GetDistrictsData, GetDistrictsResponses, GetEmailChangeRequestsData, GetEmailChangeRequestsResponses, GetEventLocationsByGridData, GetEventLocationsByGridResponses, GetEventsData, GetEventsResponses, GetExploreRegionsData, GetExploreRegionsResponses, GetFriendGridAggregatesData, GetFriendGridAggregatesResponses, GetFriendGridsData, GetFriendGridsResponses, GetFriendGridVideosData, GetFriendGridVideosResponses, GetFriendProfileData, GetFriendProfileResponses, GetFriendsData, GetFriendsResponses, GetGridCoverData, GetGridCoverResponses, GetGridGlobalVideosData, GetGridGlobalVideosResponses, GetGridHourlyUploadsData, GetGridHourlyUploadsResponses, GetGridVideosData, GetGridVideosResponses, GetHotZoneAggregatesData, GetHotZoneAggregatesResponses, GetHotZonesData, GetHotZonesResponses, GetInboxData, GetInboxResponses, GetLocationsData, GetLocationsResponses, GetLocationVideosData, GetLocationVideosResponses, GetMeData, GetMeResponses, GetMissionAggregatesData, GetMissionAggregatesResponses, GetMissionDetailData, GetMissionDetailResponses, GetMissionsByGridData, GetMissionsByGridResponses, GetMissionVideosData, GetMissionVideosResponses, GetMyFriendCodeData, GetMyFriendCodeResponses, GetMyProgressData, GetMyProgressResponses, GetMySubmissionsData, GetMySubmissionsResponses, GetNationalStatData, GetNationalStatResponses, GetOccupiedAggregatesInViewportData, GetOccupiedAggregatesInViewportResponses, GetOccupiedInViewportData, GetOccupiedInViewportResponses, GetOccurrenceDetailData, GetOccurrenceDetailResponses, GetOccurrencesInViewportData, GetOccurrencesInViewportResponses, GetPlaybackData, GetPlaybackResponses, GetPreferencesData, GetPreferencesResponses, GetProfileData, GetProfileResponses, GetReceivedRequestsData, GetReceivedRequestsResponses, GetRegionGridsData, GetRegionGridsResponses, GetRegionVideosData, GetRegionVideosResponses, GetReportsData, GetReportsResponses, GetRequestData, GetRequestResponses, GetRequestsData, GetRequestsResponses, GetStatByGridData, GetStatByGridResponses, GetStatByPointData, GetStatByPointResponses, GetStatsData, GetStatsResponses, GetStatusData, GetStatusResponses, GetSubmission1Data, GetSubmission1Responses, GetSubmissionData, GetSubmissionResponses, GetSubmissionsData, GetSubmissionsResponses, GetSummaryData, GetSummaryResponses, GetTrendingKeywordsData, GetTrendingKeywordsResponses, GetUnreadCountData, GetUnreadCountResponses, GetUploadHistoryData, GetUploadHistoryResponses, GetVideoDetailData, GetVideoDetailResponses, GetVideoForReviewData, GetVideoForReviewResponses, GetViewerCountData, GetViewerCountResponses, GetZonesData, GetZonesResponses, HeartbeatData, HeartbeatResponses, HighlightPreviewData, HighlightPreviewResponses, IssueDirectData, IssueDirectResponses, IssueImagePresignedUrlData, IssueImagePresignedUrlResponses, IssuePresignedUrlData, IssuePresignedUrlResponses, IssueProfileImagePresignedUrlData, IssueProfileImagePresignedUrlResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MarkAllReadData, MarkAllReadResponses, MarkReadData, MarkReadResponses, OauthCodeLoginData, OauthCodeLoginResponses, OauthLoginData, OauthLoginResponses, PreviewData, PreviewResponses, RecommendData, RecommendResponses, RedirectToKakaoAuthorizeData, RedirectToKakaoAuthorizeResponses, RegisterData, RegisterResponses, ReissueData, ReissueResponses, Reject1Data, Reject1Responses, Reject2Data, Reject2Responses, Reject3Data, Reject3Responses, RejectData, RejectEmailChangeData, RejectEmailChangeResponses, RejectResponses, RemoveHelpfulData, RemoveHelpfulResponses, RemoveProfileImageData, RemoveProfileImageResponses, ReplaceData, ReplaceFeaturedData, ReplaceFeaturedResponses, ReplaceResponses, ReportData, ReportResponses, RequestData, RequestEmailChangeData, RequestEmailChangeResponses, RequestResetData, RequestResetResponses, RequestResponses, ResendPasswordData, ResendPasswordResponses, ResetPasswordData, ResetPasswordResponses, ResubmitData, ResubmitResponses, ReverseGeocodeData, ReverseGeocodeResponses, SearchPlacesData, SearchPlacesResponses, SetInitialPasswordData, SetInitialPasswordResponses, SetVisibilityData, SetVisibilityResponses, SignupData, SignupResponses, SocialLoginData, SocialLoginResponses, SubmitConsentsData, SubmitConsentsResponses, SubmitData, SubmitResponses, UnblindVideoData, UnblindVideoResponses, UnpublishData, UnpublishResponses, UnregisterData, UnregisterResponses, UpdateCommentData, UpdateCommentResponses, UpdateData, UpdateLocationConsentData, UpdateLocationConsentResponses, UpdateMarketingConsentData, UpdateMarketingConsentResponses, UpdateNicknameData, UpdateNicknameResponses, UpdateProfileData, UpdateProfileImageData, UpdateProfileImageResponses, UpdateProfileResponses, UpdateResponses, UpdateSubscriptionData, UpdateSubscriptionResponses, Upload1Data, Upload1Responses, UploadData, UploadMissionVideoData, UploadMissionVideoResponses, UploadResponses, WalkPathsData, WalkPathsResponses } from './types.gen';
+import type { AcceptData, AcceptResponses, AddHelpfulData, AddHelpfulResponses, Approve1Data, Approve1Responses, Approve2Data, Approve2Responses, ApproveData, ApproveEmailChangeData, ApproveEmailChangeResponses, ApproveResponses, BlockData, BlockResponses, ChangePasswordData, ChangePasswordResponses, CreateCommentData, CreateCommentResponses, CreateData, CreateResponses, DeleteCommentData, DeleteCommentResponses, DeleteData, DeleteFriendData, DeleteFriendResponses, DeleteMeData, DeleteMeResponses, DeleteResponses, FindMyBadgesData, FindMyBadgesResponses, GetAccountsData, GetAccountsResponses, GetActiveMissionsInViewportData, GetActiveMissionsInViewportResponses, GetApprovedEventsData, GetApprovedEventsResponses, GetBlockedUsersData, GetBlockedUsersResponses, GetCellData, GetCellResponses, GetCollectionGridsData, GetCollectionGridsResponses, GetCommentsData, GetCommentsResponses, GetConsentStatusData, GetConsentStatusResponses, GetDistrictsData, GetDistrictsResponses, GetEmailChangeRequestsData, GetEmailChangeRequestsResponses, GetEventLocationsByGridData, GetEventLocationsByGridResponses, GetEventsData, GetEventsResponses, GetExploreRegionsData, GetExploreRegionsResponses, GetFriendGridAggregatesData, GetFriendGridAggregatesResponses, GetFriendGridsData, GetFriendGridsResponses, GetFriendGridVideosData, GetFriendGridVideosResponses, GetFriendProfileData, GetFriendProfileResponses, GetFriendsData, GetFriendsResponses, GetGridCoverData, GetGridCoverResponses, GetGridGlobalVideosData, GetGridGlobalVideosResponses, GetGridHourlyUploadsData, GetGridHourlyUploadsResponses, GetGridVideosData, GetGridVideosResponses, GetHotZoneAggregatesData, GetHotZoneAggregatesResponses, GetHotZonesData, GetHotZonesResponses, GetInboxData, GetInboxResponses, GetLocationsData, GetLocationsResponses, GetLocationVideosData, GetLocationVideosResponses, GetMeData, GetMeResponses, GetMissionAggregatesData, GetMissionAggregatesResponses, GetMissionDetailData, GetMissionDetailResponses, GetMissionsByGridData, GetMissionsByGridResponses, GetMissionVideosData, GetMissionVideosResponses, GetMyFriendCodeData, GetMyFriendCodeResponses, GetMyProgressData, GetMyProgressResponses, GetMySubmissionsData, GetMySubmissionsResponses, GetNationalStatData, GetNationalStatResponses, GetOccupiedAggregatesInViewportData, GetOccupiedAggregatesInViewportResponses, GetOccupiedInViewportData, GetOccupiedInViewportResponses, GetOccurrenceDetailData, GetOccurrenceDetailResponses, GetOccurrencesInViewportData, GetOccurrencesInViewportResponses, GetPlaybackData, GetPlaybackResponses, GetPreferencesData, GetPreferencesResponses, GetProfileData, GetProfileResponses, GetReceivedRequestsData, GetReceivedRequestsResponses, GetRegionGridsData, GetRegionGridsResponses, GetRegionVideosData, GetRegionVideosResponses, GetReportsData, GetReportsResponses, GetRequestData, GetRequestResponses, GetRequestsData, GetRequestsResponses, GetStatByGridData, GetStatByGridResponses, GetStatByPointData, GetStatByPointResponses, GetStatsData, GetStatsResponses, GetStatusData, GetStatusResponses, GetSubmission1Data, GetSubmission1Responses, GetSubmissionData, GetSubmissionResponses, GetSubmissionsData, GetSubmissionsResponses, GetSummaryData, GetSummaryResponses, GetTrendingKeywordsData, GetTrendingKeywordsResponses, GetUnreadCountData, GetUnreadCountResponses, GetUploadHistoryData, GetUploadHistoryResponses, GetVideoDetailData, GetVideoDetailResponses, GetVideoForReviewData, GetVideoForReviewResponses, GetViewerCountData, GetViewerCountResponses, GetZonesData, GetZonesResponses, HeartbeatData, HeartbeatResponses, HighlightPreviewData, HighlightPreviewResponses, IssueDirectData, IssueDirectResponses, IssueImagePresignedUrlData, IssueImagePresignedUrlResponses, IssuePresignedUrlData, IssuePresignedUrlResponses, IssueProfileImagePresignedUrlData, IssueProfileImagePresignedUrlResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MarkAllReadData, MarkAllReadResponses, MarkReadData, MarkReadResponses, OauthCodeLoginData, OauthCodeLoginResponses, OauthLoginData, OauthLoginResponses, PreviewData, PreviewResponses, Probe1Data, Probe1Responses, ProbeData, ProbeResponses, RecommendData, RecommendResponses, RedirectToKakaoAuthorizeData, RedirectToKakaoAuthorizeResponses, RegisterData, RegisterResponses, ReissueData, ReissueResponses, Reject1Data, Reject1Responses, Reject2Data, Reject2Responses, Reject3Data, Reject3Responses, RejectData, RejectEmailChangeData, RejectEmailChangeResponses, RejectResponses, RemoveHelpfulData, RemoveHelpfulResponses, RemoveProfileImageData, RemoveProfileImageResponses, ReplaceData, ReplaceFeaturedData, ReplaceFeaturedResponses, ReplaceResponses, ReportData, ReportResponses, RequestData, RequestEmailChangeData, RequestEmailChangeResponses, RequestResetData, RequestResetResponses, RequestResponses, ResendPasswordData, ResendPasswordResponses, ResetPasswordData, ResetPasswordResponses, ResubmitData, ResubmitResponses, ReverseGeocodeData, ReverseGeocodeResponses, SearchPlacesData, SearchPlacesResponses, SetInitialPasswordData, SetInitialPasswordResponses, SetVisibilityData, SetVisibilityResponses, SignupData, SignupResponses, SocialLoginData, SocialLoginResponses, SubmitConsentsData, SubmitConsentsResponses, SubmitData, SubmitResponses, UnblindVideoData, UnblindVideoResponses, UnblockData, UnblockResponses, UnpublishData, UnpublishResponses, UnregisterData, UnregisterResponses, UpdateCommentData, UpdateCommentResponses, UpdateData, UpdateLocationConsentData, UpdateLocationConsentResponses, UpdateMarketingConsentData, UpdateMarketingConsentResponses, UpdateNicknameData, UpdateNicknameResponses, UpdateProfileData, UpdateProfileImageData, UpdateProfileImageResponses, UpdateProfileResponses, UpdateResponses, UpdateSubscriptionData, UpdateSubscriptionResponses, Upload1Data, Upload1Responses, UploadData, UploadMissionVideoData, UploadMissionVideoResponses, UploadResponses, WalkPathsData, WalkPathsResponses, WhoamiData, WhoamiResponses } from './types.gen';
export type Options = Options2 & {
/**
@@ -282,6 +282,28 @@ export const highlightPreview = (options:
}
});
+/**
+ * 사용자 차단 해제
+ *
+ * 내가 걸은 차단을 푼다. 차단한 적 없는 사용자나 존재하지 않는 userId 도 200 이다(멱등). 차단으로 삭제된 친구 관계는 되살아나지 않는다. 상대가 나를 차단한 행은 그대로라 그 경우 서로의 콘텐츠는 계속 보이지 않는다.
+ */
+export const unblock = (options: Options): RequestResult => (options.client ?? client).delete({
+ security: [{ scheme: 'bearer', type: 'http' }],
+ url: '/api/users/{userId}/block',
+ ...options
+});
+
+/**
+ * 사용자 차단
+ *
+ * 경로의 사용자를 차단한다. 차단하면 두 사람 사이의 친구 관계(수락됨·대기 중, 방향 무관)가 함께 삭제되고, 이후 서로의 영상과 댓글이 목록·재생·상세에서 보이지 않는다. 이미 차단한 사용자를 다시 차단해도 성공하며 최초 차단 시각이 유지된다(멱등). 자기 자신은 400 + 1430, 존재하지 않는 사용자는 404 + 1404 다. 신고는 차단과 무관하게 계속 할 수 있다.
+ */
+export const block = (options: Options): RequestResult => (options.client ?? client).post({
+ security: [{ scheme: 'bearer', type: 'http' }],
+ url: '/api/users/{userId}/block',
+ ...options
+});
+
/**
* 프로필 이미지 업로드용 presigned URL 발급
*
@@ -434,7 +456,7 @@ export const register = (options: Options<
/**
* 미션 영상 목록 조회
*
- * 그 미션의 대상 격자에서 미션 기간에 촬영된 공개(PUBLIC)·READY 영상을 촬영 시각(recordedAt) 최신순으로 페이지 조회한다 — 촬영 시각이 같으면 videoId 내림차순으로 갈린다. 기간이 없는 미션(코스·지속형)은 기간 조건 없이 과거 영상까지 담고, 기간이 끝난 미션도 목록은 그대로 조회된다. 비공개·친구 공개·삭제·블라인드·인코딩 미완 영상은 본인 것이라도 제외되며, 응답은 누가 부르든 같다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 커서는 발급된 그 미션 전용이라 다른 미션 커서는 400(INVALID_CURSOR)이고, 형식이 깨진 커서도 같다. size 는 1~50 밖이면 클램프된다. 조건에 맞는 영상이 없거나 존재하지 않는 missionId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.
+ * 그 미션의 대상 격자에서 미션 기간에 촬영된 공개(PUBLIC)·READY 영상을 촬영 시각(recordedAt) 최신순으로 페이지 조회한다 — 촬영 시각이 같으면 videoId 내림차순으로 갈린다. 기간이 없는 미션(코스·지속형)은 기간 조건 없이 과거 영상까지 담고, 기간이 끝난 미션도 목록은 그대로 조회된다. 비공개·친구 공개·삭제·블라인드·인코딩 미완 영상은 본인 것이라도 제외된다. 로그인 요청이면 요청자와 차단 관계(어느 방향이든)인 작성자의 영상도 빠지고, 그 밖에는 응답이 누가 부르든 같다. 첫 요청은 cursor 없이 부르고, hasNext 가 true 면 응답의 nextCursor 를 다음 요청 cursor 로 넘기면 이어진다. 커서는 발급된 그 미션 전용이라 다른 미션 커서는 400(INVALID_CURSOR)이고, 형식이 깨진 커서도 같다. size 는 1~50 밖이면 클램프된다. 조건에 맞는 영상이 없거나 존재하지 않는 missionId 는 빈 페이지다. 썸네일은 presigned GET URL 로 내려준다.
*/
export const getMissionVideos = (options: Options): RequestResult => (options.client ?? client).get({
security: [{ scheme: 'bearer', type: 'http' }],
@@ -509,7 +531,7 @@ export const accept = (options: Options(options: Options): RequestResult => (options.client ?? client).get({
security: [{ scheme: 'bearer', type: 'http' }],
@@ -545,7 +567,7 @@ export const createComment = (options: Opt
*
* cursor 는 직전 응답의 nextCursor 를 그대로 넣는다(첫 페이지는 생략). 형식이 깨졌거나 다른 위치 피드에서 받은 커서면 400 + developCode 13402 다. size 는 1~50 범위 밖이면 잘라서 적용하고 생략하면 20 이다.
*
- * 아카이브된 행사에서도 조회할 수 있고 영상이 없으면 실패가 아니라 빈 페이지다. 존재하지 않거나 노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다. 비로그인으로도 조회할 수 있다.
+ * 아카이브된 행사에서도 조회할 수 있고 영상이 없으면 실패가 아니라 빈 페이지다. 존재하지 않거나 노출 기간 전인 회차는 404 + 13404, 위치가 없거나 그 회차의 위치가 아니면 404 + 13405 다. 비로그인으로도 조회할 수 있다. 로그인 요청이면 요청자와 차단 관계(어느 방향이든)인 작성자의 영상은 빠지고(위치 카드의 영상 수는 그대로다), 항목의 uploaderId 는 작성자 식별자라 차단(POST /api/users/{userId}/block)의 경로 값으로 쓴다.
*/
export const getLocationVideos = (options: Options): RequestResult => (options.client ?? client).get({
security: [{ scheme: 'bearer', type: 'http' }],
@@ -688,7 +710,7 @@ export const changePassword = (options: Op
/**
* 소셜 로그인 (OIDC)
*
- * 소셜 제공자의 ID Token으로 로그인/가입하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키(Set-Cookie)로 내려가 body 의 refreshToken 이 null 이고, 앱(app)은 body 로 내려간다.
+ * 소셜 제공자의 ID Token으로 로그인/가입하고 JWT 액세스 토큰과 리프레시 토큰을 발급받는다. 웹(X-Client-Type: web, 기본)은 리프레시가 HttpOnly 쿠키(Set-Cookie)로 내려가 body 의 refreshToken 이 null 이고, 앱(app)은 body 로 내려간다. provider=apple 은 nonce 원문과 authorizationCode 가 필수이고(첫 로그인에서만 애플 토큰 교환), fullName 은 계정 생성 때만 닉네임으로 쓴다.
*/
export const oauthLogin = (options: Options): RequestResult => (options.client ?? client).post({
security: [{ scheme: 'bearer', type: 'http' }],
@@ -843,7 +865,7 @@ export const resendPassword = (options: Op
/**
* 계정 발급 요청 반려
*
- * 요청을 반려하고 사유를 저장한다. 사유는 필수이며 메일은 발송되지 않는다 — 반려 통보는 당분간 수기이고 저장된 사유가 그 재료다.
+ * 요청을 반려하고 사유를 저장한 뒤, 요청자의 공식 이메일로 반려 사유를 담은 안내 메일을 발송한다 (필맵 서식 HTML + 평문 대체본). 사유는 필수다. 발송 실패는 반려를 뒤집지 않고 emailSent:false 로만 드러나며, 그때는 저장된 사유로 수기 통보한다(재발송 API 없음).
*
* 없는 요청은 404(1421), 이미 처리된 요청은 409(1422), 검토 이후 요청 내용이 바뀌었으면 409(1426) 다.
*/
@@ -1111,6 +1133,12 @@ export const updateComment = (options: Opt
}
});
+export const whoami = (options?: Options): RequestResult => (options?.client ?? client).get({
+ security: [{ scheme: 'bearer', type: 'http' }],
+ url: '/test/protected',
+ ...options
+});
+
/**
* 구역 목록 조회
*
@@ -1144,6 +1172,17 @@ export const getMe = (options?: Options(options?: Options): RequestResult => (options?.client ?? client).get({
+ security: [{ scheme: 'bearer', type: 'http' }],
+ url: '/api/users/me/blocks',
+ ...options
+});
+
/**
* 인기 검색어 TOP 10
*
@@ -1282,6 +1321,12 @@ export const getMySubmissions = (options?:
...options
});
+export const probe = (options?: Options): RequestResult => (options?.client ?? client).get({
+ security: [{ scheme: 'bearer', type: 'http' }],
+ url: '/api/org/authorization-probe',
+ ...options
+});
+
/**
* 알림 목록 조회
*
@@ -1430,7 +1475,7 @@ export const getCell = (options: Options(options: Options): RequestResult => (options.client ?? client).get({
security: [{ scheme: 'bearer', type: 'http' }],
@@ -1617,7 +1662,7 @@ export const getMyFriendCode = (options?:
*
* 영상 하나의 재생본 presigned GET URL 과 표시 재료를 돌려준다. 소속 행사 회차·위치·대표 격자와 그 표시명 재료가 함께 담겨, 상세 화면이 추가 호출 없이 위치줄을 그린다.
*
- * 피드에 보이는 영상만 열린다 — 삭제·블라인드·비공개·처리 미완료 영상은 올린 본인에게도 404 + developCode 13406 이다(본인 영상 확인은 GET /api/videos/{videoId}). 행사 영상이 아닌 영상 id 도 같은 404 다.
+ * 피드에 보이는 영상만 열린다 — 삭제·블라인드·비공개·처리 미완료 영상은 올린 본인에게도 404 + developCode 13406 이다(본인 영상 확인은 GET /api/videos/{videoId}). 행사 영상이 아닌 영상 id 도 같은 404 이고, 작성자와 차단 관계(어느 방향이든)인 요청자에게도 같은 404 다.
*
* interactionLocked 는 아카이브 전환(행사 종료 + 30일)부터 true 이며 댓글·도움돼요 입력 UI 를 비활성화하는 재료다(기존 수는 계속 표시. 유예 기간에는 반응을 계속 남길 수 있다). 재생 URL 을 발급받은 타인 조회는 조회수를 올린다 — 비로그인 조회도 포함이고 올린 본인은 제외다.
*/
@@ -1630,7 +1675,7 @@ export const getVideoDetail = (options: Op
/**
* 뷰포트 내 행사 회차 목록 조회
*
- * 지도 화면 bbox(남서~북동 좌표) 안에 노출 영역이 걸친 행사 회차를 반환한다. 담기는 것은 진행 중이거나, 시작 2주 전부터의 노출 기간에 든 예정 회차뿐이다 — 종료된 행사(업로드 유예·아카이브)는 칩에 담기지 않고 상세·격자 역조회로만 접근한다. 아직 노출 기간 전인 예정 회차는 존재 자체를 숨긴다.
+ * 지도 화면 bbox(남서~북동 좌표) 안에 노출 영역이 걸친 행사 회차를 반환한다. 담기는 것은 진행 중이거나, 시작 2주 전부터의 노출 기간에 든 예정 회차이거나, 종료 뒤 30일의 업로드 유예 기간에 든 회차다 — 아카이브(종료 30일 경과) 회차만 칩에서 빠지고 상세·격자 역조회로 접근한다. 아직 노출 기간 전인 예정 회차는 존재 자체를 숨긴다.
*
* 정렬은 시 이름 → 시작일 → 회차 id 오름차순이라, 시 칩 아래에 그 시의 행사 칩을 나열하는 화면이 매 요청 같은 순서를 받는다. 보이는 범위에 행사가 없으면 실패가 아니라 빈 배열이다.
*
@@ -1736,6 +1781,12 @@ export const findMyBadges = (options?: Opt
...options
});
+export const probe1 = (options?: Options): RequestResult => (options?.client ?? client).get({
+ security: [{ scheme: 'bearer', type: 'http' }],
+ url: '/api/authorization-probe',
+ ...options
+});
+
/**
* 비밀번호 강제 변경 상태 조회
*
diff --git a/apps/web/src/shared/api/generated/types.gen.ts b/apps/web/src/shared/api/generated/types.gen.ts
index 77933c09..fb60b350 100644
--- a/apps/web/src/shared/api/generated/types.gen.ts
+++ b/apps/web/src/shared/api/generated/types.gen.ts
@@ -794,6 +794,10 @@ export type RouteRecommendResponseDto = {
* 언급 지역 신호 (MSG-468) — 문장이 화면 밖 지역을 말했으면 이동·축소 제안 재료가 실린다. 무신호(지역 무언급·동명 다수·대조 실패·충분히 담김)가 기본값
*/
mentionedArea: MentionedAreaDto | null;
+ /**
+ * 동선 전체의 종합 추천 이유(FR-ROUTE-20). 사용자 문장을 근거로 이번 지점 구성을 설명한다. 빈 목록 응답(후보 없음, 무관 문장, 도보 절단)은 null이고 notice가 그 자리를 맡는다
+ */
+ summary: string | null;
};
/**
@@ -1307,9 +1311,21 @@ export type PasswordChangeRequestDto = {
*/
export type OidcLoginRequestDto = {
/**
- * 소셜 제공자(카카오 등)에서 발급받은 OIDC ID Token
+ * 소셜 제공자(카카오·애플)에서 발급받은 OIDC ID Token
*/
idToken: string;
+ /**
+ * 앱이 요청마다 만든 nonce 원문 (APPLE 필수, 카카오는 무시). 앱은 이 값의 SHA-256 16진 소문자를 애플 로그인 시트에 넘기고 원문을 서버에 보낸다 — 원문을 시트에 넘기면 대조가 항상 실패한다(2421)
+ */
+ nonce?: string;
+ /**
+ * 애플 authorizationCode (APPLE 필수). 첫 로그인인지 가리지 말고 매번 보낸다 — 서버가 계정이 없을 때만 교환한다. 5분 안에 1회만 교환 가능
+ */
+ authorizationCode?: string;
+ /**
+ * 애플이 첫 승인에만 주는 이름을 한 문자열로 조립한 값 (선택). 계정을 새로 만들 때만 닉네임으로 쓰고, 2~20자 밖이면 기본 닉네임(필맵러+4자리)으로 대체한다
+ */
+ fullName?: string;
};
export type ApiResponseDtoLoginResponseDto = {
@@ -1379,7 +1395,7 @@ export type LoginRequestDto = {
*/
export type DevSocialLoginRequestDto = {
/**
- * 소셜 제공자 (기본 KAKAO)
+ * 소셜 제공자 (기본 KAKAO, APPLE 가능 — 애플 왕복 없이 계정만 만든다)
*/
provider?: string;
/**
@@ -1511,7 +1527,7 @@ export type OrgAccountResendResponseDto = {
*/
export type OrgAccountRequestRejectRequestDto = {
/**
- * 반려 사유 (최대 500자). 관리자가 신청자에게 수기로 통보할 때 쓴다
+ * 반려 사유 (최대 500자). 요청자에게 발송되는 반려 안내 메일에 그대로 실린다
*/
reason: string;
/**
@@ -1520,6 +1536,22 @@ export type OrgAccountRequestRejectRequestDto = {
updatedAt: string;
};
+export type ApiResponseDtoOrgAccountRequestRejectResponseDto = {
+ developCode: number;
+ message: string;
+ data: OrgAccountRequestRejectResponseDto;
+};
+
+/**
+ * 계정 발급 요청 반려 결과
+ */
+export type OrgAccountRequestRejectResponseDto = {
+ /**
+ * 반려 안내 메일 발송 성공 여부. true 는 SES 접수까지의 성공이고 배달 확인은 아니다
+ */
+ emailSent: boolean;
+};
+
/**
* 계정 발급 요청 승인 요청
*/
@@ -1922,6 +1954,38 @@ export type VideoPlaybackResponseDto = {
* 작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다
*/
nickname: string;
+ /**
+ * 작성자 사용자 ID (videos.user_id). 차단(POST /api/users/{userId}/block)의 경로 값
+ */
+ userId: number;
+};
+
+export type ApiResponseDtoListBlockedUserResponseDto = {
+ developCode: number;
+ message: string;
+ data: Array;
+};
+
+/**
+ * 내가 차단한 사용자 목록 항목
+ */
+export type BlockedUserResponseDto = {
+ /**
+ * 차단한 사용자 ID. 해제(DELETE /api/users/{userId}/block)의 경로 값
+ */
+ userId: number;
+ /**
+ * 닉네임 원문(조회 시점 값, 사본 아님)
+ */
+ nickname: string;
+ /**
+ * 프로필 이미지 URL. 없으면 null
+ */
+ profileImageUrl: string | null;
+ /**
+ * 차단 시각(최초 차단 시각, 재차단해도 바뀌지 않는다)
+ */
+ blockedAt: string;
};
export type ApiResponseDtoListTrendingKeywordResponseDto = {
@@ -2836,6 +2900,10 @@ export type GridGlobalVideoResponseDto = {
* 작성자 닉네임 원문. @ 등 화면 표기는 FE 가 붙인다
*/
nickname: string;
+ /**
+ * 작성자 사용자 ID (videos.user_id). 차단(POST /api/users/{userId}/block)의 경로 값
+ */
+ userId: number;
};
/**
@@ -3668,6 +3736,10 @@ export type EventVideoDetailResponseDto = {
* 댓글 첫 페이지 (오래된 순 20건)
*/
comments: EventVideoCommentPageResponseDto;
+ /**
+ * 작성자 사용자 ID (videos.user_id). 차단(POST /api/users/{userId}/block)의 경로 값
+ */
+ uploaderId: number;
};
export type ApiResponseDtoEventVideoCommentPageResponseDto = {
@@ -3707,9 +3779,9 @@ export type EventOccurrenceChipResponseDto = {
*/
endsAt: string;
/**
- * 서버 시각 기준 파생 상태 — 이 목록에는 두 값만 담긴다
+ * 서버 시각 기준 파생 상태 — 이 목록에는 세 값만 담긴다 (아카이브 회차는 빠진다)
*/
- status: 'UPCOMING' | 'LIVE';
+ status: 'UPCOMING' | 'LIVE' | 'UPLOAD_GRACE';
};
export type ApiResponseDtoEventOccurrenceDetailResponseDto = {
@@ -3758,6 +3830,10 @@ export type EventOccurrenceDetailResponseDto = {
* 같은 시리즈의 지난 회차 — 최신순. 없으면 빈 배열
*/
previousOccurrences: Array;
+ /**
+ * 대표 이미지 공개 URL — 이미지가 없는 회차는 null 이고 나머지 필드는 그대로다
+ */
+ imageUrl: string | null;
};
/**
@@ -3869,7 +3945,7 @@ export type EventLocationResponseDto = {
*/
participationMethod: string | null;
/**
- * 커버 이미지 공개 URL — 참여형 승인분만 값이 있다
+ * 커버 이미지 공개 URL — 참여형 승인분과 시드 위치에 값이 있다. 없으면 null 이고 나머지 필드는 그대로다
*/
imageUrl: string | null;
};
@@ -3926,6 +4002,10 @@ export type EventLocationVideoResponseDto = {
* 댓글 수
*/
commentCount: number;
+ /**
+ * 작성자 사용자 ID (videos.user_id). 차단(POST /api/users/{userId}/block)의 경로 값
+ */
+ uploaderId: number;
};
export type ApiResponseDtoListRegionVideoResponseDto = {
@@ -5148,6 +5228,44 @@ export type HighlightPreviewResponses = {
export type HighlightPreviewResponse = HighlightPreviewResponses[keyof HighlightPreviewResponses];
+export type UnblockData = {
+ body?: never;
+ path: {
+ /**
+ * 차단을 해제할 사용자 ID
+ */
+ userId: number;
+ };
+ query?: never;
+ url: '/api/users/{userId}/block';
+};
+
+export type UnblockResponses = {
+ /**
+ * OK
+ */
+ 200: unknown;
+};
+
+export type BlockData = {
+ body?: never;
+ path: {
+ /**
+ * 차단할 사용자 ID
+ */
+ userId: number;
+ };
+ query?: never;
+ url: '/api/users/{userId}/block';
+};
+
+export type BlockResponses = {
+ /**
+ * OK
+ */
+ 200: unknown;
+};
+
export type IssueProfileImagePresignedUrlData = {
body: ProfileImagePresignRequestDto;
path?: never;
@@ -5625,7 +5743,7 @@ export type OauthLoginData = {
};
path: {
/**
- * 소셜 제공자
+ * 소셜 제공자 (KAKAO|APPLE)
*/
provider: string;
};
@@ -5882,9 +6000,11 @@ export type Reject2Responses = {
/**
* OK
*/
- 200: unknown;
+ 200: ApiResponseDtoOrgAccountRequestRejectResponseDto;
};
+export type Reject2Response = Reject2Responses[keyof Reject2Responses];
+
export type Approve1Data = {
body: OrgAccountRequestApproveRequestDto;
path: {
@@ -6204,6 +6324,24 @@ export type UpdateCommentResponses = {
export type UpdateCommentResponse = UpdateCommentResponses[keyof UpdateCommentResponses];
+export type WhoamiData = {
+ body?: never;
+ path?: never;
+ query?: never;
+ url: '/test/protected';
+};
+
+export type WhoamiResponses = {
+ /**
+ * OK
+ */
+ 200: {
+ [key: string]: unknown;
+ };
+};
+
+export type WhoamiResponse = WhoamiResponses[keyof WhoamiResponses];
+
export type GetZonesData = {
body?: never;
path?: never;
@@ -6253,6 +6391,22 @@ export type GetMeResponses = {
export type GetMeResponse = GetMeResponses[keyof GetMeResponses];
+export type GetBlockedUsersData = {
+ body?: never;
+ path?: never;
+ query?: never;
+ url: '/api/users/me/blocks';
+};
+
+export type GetBlockedUsersResponses = {
+ /**
+ * OK
+ */
+ 200: ApiResponseDtoListBlockedUserResponseDto;
+};
+
+export type GetBlockedUsersResponse = GetBlockedUsersResponses[keyof GetBlockedUsersResponses];
+
export type GetTrendingKeywordsData = {
body?: never;
path?: never;
@@ -6521,6 +6675,24 @@ export type GetMySubmissionsResponses = {
export type GetMySubmissionsResponse = GetMySubmissionsResponses[keyof GetMySubmissionsResponses];
+export type ProbeData = {
+ body?: never;
+ path?: never;
+ query?: never;
+ url: '/api/org/authorization-probe';
+};
+
+export type ProbeResponses = {
+ /**
+ * OK
+ */
+ 200: {
+ [key: string]: unknown;
+ };
+};
+
+export type ProbeResponse = ProbeResponses[keyof ProbeResponses];
+
export type GetInboxData = {
body?: never;
path?: never;
@@ -7407,6 +7579,24 @@ export type FindMyBadgesResponses = {
export type FindMyBadgesResponse = FindMyBadgesResponses[keyof FindMyBadgesResponses];
+export type Probe1Data = {
+ body?: never;
+ path?: never;
+ query?: never;
+ url: '/api/authorization-probe';
+};
+
+export type Probe1Responses = {
+ /**
+ * OK
+ */
+ 200: {
+ [key: string]: unknown;
+ };
+};
+
+export type Probe1Response = Probe1Responses[keyof Probe1Responses];
+
export type GetStatusData = {
body?: never;
path?: never;
diff --git a/apps/web/src/test/event-video-fixture.ts b/apps/web/src/test/event-video-fixture.ts
index 53fff261..81b8c1f3 100644
--- a/apps/web/src/test/event-video-fixture.ts
+++ b/apps/web/src/test/event-video-fixture.ts
@@ -21,6 +21,7 @@ export const eventComment = (
export const EVENT_VIDEO_DETAIL: EventVideoDetailResponseDto = {
videoId: 42,
+ uploaderId: 7,
occurrenceId: 7,
occurrenceStatus: "LIVE",
locationId: 4,
diff --git a/apps/web/src/test/playback-fixture.ts b/apps/web/src/test/playback-fixture.ts
index 21cd4346..ae4ffce4 100644
--- a/apps/web/src/test/playback-fixture.ts
+++ b/apps/web/src/test/playback-fixture.ts
@@ -7,6 +7,7 @@ import type { VideoPlaybackResponseDto } from "@/shared/api/generated";
*/
export const READY_PLAYBACK: VideoPlaybackResponseDto = {
videoId: 42,
+ userId: 7,
nickname: "필맵퍼",
playbackUrl: "https://cdn.example.com/blurred.mp4",
thumbnailUrl: null,
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 01e7accc..03905648 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -152,6 +152,7 @@
- MSG-582: 네이버 지도 RN 라이브러리(`@mj-studio/react-native-naver-map` 2.9.0) **pnpm 패치 2건째** — `RNCNaverMapMarker.removeCustomView()`의 `customViewBitmap.recycle()` 제거(참조만 끊고 GC에 맡김). 커스텀 뷰 마커(경로 번호·클러스터·미션 이름표) 언마운트 시 GL 렌더 스레드가 recycle된 비트맵을 잠그려다 네이티브 abort로 앱이 통째로 꺼지던 라이브러리 버그. 패치 파일은 `patches/@mj-studio__react-native-naver-map@2.9.0.patch` 하나(MSG-445 onLoad `topLoaded` 패치와 같은 파일). Kotlin 변경이라 dev client 재빌드 필요 — 런북 함정 12
- MSG-590: 온보딩 3장 **시안 교체** — `features/onboarding`이 진행바+코드 드로잉 히어로 카드 구성에서 **일러스트 SVG 중심** 구성으로 재작성(Figma `미리보기 · 온보딩 1~3 (시안 적용)` 14902:476/581/626). `assets/illust-{fill,record,explore}.ts`(Figma export SVG → XML 문자열 모듈, `SvgXml` 렌더 — MSG-430 뱃지 아트 경로. primary·foreground·white 3종만 토큰 보간, 나머지 hex·3장 빨강 `#F5533D` 시안값 유지) + `illust.test.ts`(L5 계약). `onboarding-steps.ts`는 문구 6종 시안 원문·`descriptionLines` 2줄 튜플, `isSkipVisible`·`progressOf` 삭제. 화면: 빈 TopBar → 일러(flex-1 비율 축소) → 강조 바 → 헤드라인 → 설명 2줄 → ui-native `Dots pill`(progressbar 래퍼) → `Button pill`. **건너뛰기 삭제**(`index.tsx` onSkip 1줄 제거, 라우팅 판정 무수정). 히어로 카드 3파일 삭제, `SegmentedProgress`는 ui-native에 잔존(소비처 0). 타이포 26→display·15→base 다운스케일, Dots 16×6(시안 20×8) 오탐 방지 등재
- MSG-588: 로그인 화면 로고 자산 교체 — `features/auth/assets/fillmap-app-icon.png`(104×104 구 아이콘) → `fillmap-symbol.png`(356×416 RGBA, feelmap-logo-pack `symbol-color` 트림·4x). `login-screen.tsx` ``에 `resizeMode="contain"`(박스 `size-26` 유지, 비율 6:7), **halo 원(`size-52.5 rounded-full bg-surface`) 제거**(사용자 실기 피드백). 로직 변경 0 · 테스트 추가 0
+- MSG-570: 사용자 차단(앱스토어 UGC 1.2 요건, 서버 MSG-569) — **`features/user-block` 신설**(report-history와 같은 3층): `model/user-block.ts`(`BlockTarget`·다이얼로그 문구·4상태 `resolveBlockListState = shared/api/list-state.resolveListState`·row view `YYYY.MM.DD` KST·`removeBlockedUser`) · `api/`(`blockUser`/`unblockUser` 옵션 팩토리 + 파일 로컬 `useGuardedMutation` 연타 가드, `invalidateAfterBlockChange` — 리스트 4종+`getMissionVideos` 즉시, `getPlayback`·`getVideoDetail`은 `refetchType:"none"`, `useBlockedUsersQuery`) · `ui/`(`BlockUserDialog` = `ModalCard` 하나가 뮤테이션·실패 문구 소유 — 진입점 3곳이 공유하는 유일 지점, `BlockedUserRow`, `BlockedUsersScreen` 4상태·실패는 `DexErrorState` 재사용). 라우트 `/profile/blocks`(`PROTECTED_ROUTES` 등재, 프로필 설정 "신고 관리" 아래 행). **진입점 3곳**: ① 격자 상세 타인 행 ⋯ · ② 재생 화면 헤더 ⋯(신설, `mine=0`만) — 둘 다 `VideoMoreSheet` "신고하기" 아래 "사용자 차단" 행, A2로 **신고 모달·발사·토스트를 `VideoActionsMenu`가 흡수**(`report-modal.tsx` → video-actions/ui 이동, props `onReport` → `author?: BlockTarget`·`onBlocked?`, `grid-detail-screen` 신고 배선 순감, `GridVideoRow.author`) · ③ 이벤트 영상 시트 타인 댓글 `onLongPress`(+a11y `longpress` 액션, 내 댓글 = `getMe` 닉네임 비교) → 1행 `ActionSheet` → 같은 다이얼로그 → 성공 시 `removeCommentsByAuthor` seed + `comments.reset()` + 시트 인라인 토스트. 재생 화면 차단 성공은 토스트 없이 `router.back()`. hey-api 재생성으로 DTO에 `userId`(격자 전역·재생·차단 목록)·`uploaderId`(이벤트)·`authorId`(댓글) 추가 — 웹은 픽스처 7곳 상수 필드만. Figma 시안 없음(기존 관례 조립)
## 티켓 이력 (2026-08-13 이후 — 티켓당 한 줄 append)
@@ -242,3 +243,4 @@
- MSG-590: [모바일] 온보딩 3장을 "미리보기 · 온보딩 (시안 적용)" 시안으로 교체 — 사용자 요청(Figma 캔버스 링크 + "온보딩을 미리보기 · 온보딩 1 (시안 적용),2,3 으로 바꿔야함")으로 하네스가 티켓 생성. Figma 실측: 시안 파랑 = primary #0066CC 정확 일치, 3장 빨강 `#F5533D`는 기존 토큰(theme-hot·error)으로 불가 → 시안값 유지 승인. 일러는 Figma export SVG(path 10~15개, defs 0)를 XML 모듈 + `SvgXml`로(코드 드로잉·PNG 기각), 모듈 XML을 토큰 보간 해제 후 export와 바이트 비교해 3장 동일 확인. 승인 게이트 결정 3건(건너뛰기 삭제·빨강 시안값·Dots 기존 치수) + 기본값 3건(색 보간 범위·타이포 다운스케일·일러 flex-1 축소). 실기 3분(온보딩 완료 키를 `run-as`+sqlite로 삭제해 `pm clear` 없이 재진입, 8083 Metro + dev 메뉴 번들 주소 — MSG-588 함정 회피), L1~L5·S1~S10 전부 통과, a11y SVG 내부 focusable 0. nose 접촉 패밀리 7건만 교체. **루트 `pnpm test`의 web org 재제출 스모크 6건 실패는 develop 선재**(메인 디렉토리 develop에서 동일 재현, 이 브랜치 web diff 0) — 별도 처리 필요. 참고: 온보딩 흰 배경에서 상태바 아이콘이 묻힘(앱 전역, 범위 밖)
- MSG-588: [모바일] 로그인 화면 로고를 새 심볼로 교체 + 저해상 해소 — 사용자 요청("로고를 바꿔야해, 중앙에 뜨는거 해상도 괜찮게")으로 하네스가 티켓 생성. 팩 12종 중 투명 배경 컬러 심볼(`symbol-color`)을 골랐다(`app-icon-*`는 배경 사각형이 halo 위에 그대로 보여 부적합, `logo-full`은 워드마크가 태그라인과 중복). 경량 실행(에이전트 위임 없음), 에뮬레이터 실기는 런북 1-D(워크트리 Metro 8082 + dev 메뉴 번들 주소)로 8분. 실기 중 사용자가 "뒤에 원을 없애봐"로 halo 제거 — 새 심볼은 자체 그라데이션이라 배경 원이 불필요. 함정: 워크트리 Metro를 `CI=1`로 띄우면 감시가 꺼져 변경이 번들에 안 실리고, force-stop 콜드 스타트는 번들 주소를 8081로 되돌린다(재설정 필요). 온보딩은 로고 요소 자체가 없어 범위 밖 확인, 웹 모달·앱 아이콘·스플래시는 구 디자인 잔존(후속 티켓 후보)
- MSG-593: [웹] org 재제출·검토 스모크 6건이 2026-09-08부터 실패(CI 시한폭탄) — 행사 기간 픽스처 2026-09-05~07이 `submission-form.ts`의 "endsOn < 오늘(KST) → 과거 기간" 규칙에 걸려 제출 버튼 비활성. 마지막 통과 CI는 09-07(#151). MSG-590 검증이 최상단 고지로 잡고 develop 재현으로 선재 확정. 처방은 두 테스트 파일 `beforeEach`에서 `vi.useFakeTimers({ toFake: ["Date"] })` + `setSystemTime(2026-09-01)`(타이머는 실제 — waitFor 호환, `record-tab.smoke` 선례), `afterEach` `useRealTimers`. 제품 코드 무수정. 교훈: 오늘 기준 검증 규칙을 타는 픽스처는 리터럴 날짜 대신 Date 고정 필수
+- MSG-570: [모바일] 사용자 차단 — 타인 영상·댓글 ⋯ "사용자 차단" + 프로필 "차단한 사용자" 목록·해제(앱스토어 심사 요건). Figma 시안 없어 기존 관례(ModalCard·VideoMoreSheet·report-history 4상태) 조립으로 사용자 승인, A2(재생 ⋯ 신설 + 신고 흐름을 `VideoActionsMenu`로 흡수) 채택. dev `/v3/api-docs`가 basic auth이고 비밀 파일이 이 기기에 없어 BE 워크트리 MockMvc 테스트로 스펙을 덤프해 재생성(`servers` URL은 되돌림). 재생성이 웹 typecheck 7건(픽스처 필수 필드)을 깨서 기준 18 예외로 픽스처만 수정. 검증: 실기 3-B 11분(emulator-5556 + Metro 8082, 런북 1-D) 실패 0 — 댓글 길게 누르기 화면부(기준 10·11)는 진행 중 지역축제 0개라 확인불가(소스 대조+vitest 대체), 실패 토스트·내 영상 분기는 미실행. 함정: 딥링크 콜드 스타트는 8081 번들을 받는다 — dev 메뉴 Bundle Location으로만 8082 전환, 로그인 화면 `keyevent 82`는 카카오 버튼을 누른다. 환류: `getMe`에 `userId` 부재(BE), `DexErrorState` 4곳째(ui-native 승격 후보)
diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md
index c9f3c36a..7337437b 100644
--- a/docs/decisions/DECISIONS.md
+++ b/docs/decisions/DECISIONS.md
@@ -663,3 +663,8 @@
| 2026-09-10 | MSG-590 | 결정(승인 Q4·Q5·Q6): Dots는 ui-native `Dots activeShape="pill"`(16×6·점 6px) 그대로(시안 20×8·8px — 오탐 방지 등재), 헤드라인 26→`text-fm-display`(20)·설명 15→`text-fm-base`(14) 다운스케일, 일러는 `flex-1` 안 `SvgXml 100%×100%` 비율 유지 축소 | `Dots size` variant·타이포 토큰 신설은 이 티켓 범위 밖(MSG-421 Q3 27→20 선례). 390×485 고정은 소형 기기에서 CTA가 화면 밖으로 밀린다. 진행 상태 낭독은 `SegmentedProgress`가 하던 것을 화면 로컬 래퍼(`accessible` + `progressbar` + `accessibilityValue`)가 이어받아 ui-native 수정 0 |
| 2026-09-10 | MSG-590 | 결정(중복 게이트): `nose.baseline.json`은 삭제 히어로 3파일·재작성 `onboarding-screen.tsx`가 멤버였던 패밀리 7건만 새 스캔 대응 패밀리로 교체(6건은 내 파일만 뺀 파일집합 정확 일치, 건너뛰기 `Pressable` 8카피 패밀리 1건은 6카피로 재군집돼 부분집합 매칭). 전체 `--write-baseline` 금지 | 패밀리를 통째로 지우면 남은 멤버(22·14·6카피)가 "신규"로 떠 게이트가 exit 1이 된다(1차 시도 실측). 무관 패밀리 흔들림 0 — diff는 접촉 패밀리 7건뿐 |
| 2026-09-10 | MSG-588 | 결정: 로그인 심볼 자산은 팩 원본(720×832)이 아니라 렌더 104dp의 4x(356×416)로 축소해 번들 — 99KB | 3x 기기 기준 여유 1단계면 충분, 원본 265KB는 번들 낭비. 웹 MSG-464가 같은 4x(416px) 선례 |
+| 2026-09-12 | MSG-570 | 결정(A2 채택 구현): 신고 모달·발사·토스트를 `grid-detail-screen`에서 `VideoActionsMenu`로 흡수(`report-modal.tsx`를 video-actions/ui로 이동, 메뉴 props `onReport` → `author?: BlockTarget`·`onBlocked?`). 차단 확인은 `BlockUserDialog` 하나가 뮤테이션·실패 문구를 소유하고 진입점 3곳(격자 행·재생 헤더 ⋯·이벤트 댓글 길게 누르기)이 공유 | 재생 화면에 같은 시트가 필요해져 화면마다 신고 배선을 두면 세 화면이 갈린다. 대안(재생 ⋯는 차단만 노출)은 diff가 작지만 화면 간 메뉴 불일치 — 스펙 승인에서 기각 |
+| 2026-09-12 | MSG-570 | 결정(중복 게이트): `resolveListState`를 `shared/api/list-state.ts`로 올리고 report-history·user-block이 위임(`resolveBlockListState = resolveListState`), 차단 목록 실패 상태는 `DexErrorState` 재사용, 차단·해제 훅의 가드 래핑은 파일 로컬 `useGuardedMutation`으로 묶음. `nose.baseline.json`은 접촉 changed 22패밀리만 제자리 교체(전체 write-baseline 금지) | `pnpm check:duplication`이 정확 클론 3건(4상태 판정·에러 블록·가드 훅)을 new로 잡았다. 용서 대신 제거 — 단 report-history 1줄 위임은 스펙 밖 접촉이라 리포트에 명시. 대안(report-history 모델 import)은 이름·타입이 맞지 않아 기각 |
+| 2026-09-12 | MSG-570 | 발견: hey-api 재생성 후 **웹 typecheck 7건 실패**(`mock-cells.ts`·`playback-fixture.ts`·`event-video-fixture.ts`·테스트 3곳 — 새 필수 필드 `userId`/`uploaderId` 누락). 스펙 기준 18(웹 소스 diff 0)과 루트 typecheck 게이트가 충돌 — **픽스처 7곳에 상수 id 필드만 추가해 이 티켓에서 닫음**(기준 18 예외, 웹 소스 동작 변경 0) | 스펙 리스크 2 "웹 typecheck가 깨지면 범위 밖 별도 보고"에 해당하나, 필드 1개씩 7줄을 웹 티켓으로 분리하면 그동안 develop CI가 빨갛다. 오케스트레이터 판단, 사용자에게 사후 보고 |
+| 2026-09-12 | MSG-570 | 결정(커밋 게이트): pre-commit react-doctor가 스테이징된 `grid-detail-screen.tsx`의 `rn-scrollview-dynamic-padding`(`contentContainerStyle={{ paddingBottom: insets.bottom }}`)으로 커밋을 막았다 — **develop에 이미 있는 지적**(`--scope all`로 develop 실측: 같은 파일 162행 + `highlight-screen.tsx` 68행)이라 이 티켓에서 고치지 않고 `--no-verify`로 커밋, 나머지 훅 게이트(format:check·lint·verify-report)는 수동으로 exit 0 확인. 이 티켓이 새로 만든 복잡도 경고(`video-player-screen.tsx` no-high-complexity)는 ⋯ 버튼+메뉴를 `PlaybackActions`로 분리해 해소 | 인셋 패딩은 본질적으로 동적이고 스페이서 View 치환은 `gap-sm` 8dp를 더해 레이아웃이 바뀐다. 범위 밖 기존 지적을 이 PR에 섞지 않는다. 후속: 두 파일의 인셋 패딩 처리 방식은 별도 정리 |
+| 2026-09-12 | MSG-570 | 결정(codex 리뷰 반영, P2 3건 전부 채택): ① 차단 성공 시 `getBlockedUsers`도 무효화(목록은 seed 불가 — blockedAt·프로필 이미지는 서버만 안다) ② `onBlocked`가 **제출한 userId**를 인자로 받아 댓글 시트가 다이얼로그 상태(`blockTarget`) 대신 그 값으로 seed ③ `useEventCommentsPages`에 `generation` 세대 카운터 — `reset()` 이전에 띄운 "더 보기" 응답은 폐기 | 셋 다 MutationObserver 재현으로 확인된 실결함: 30초 stale 창 안 재진입 시 새 행 누락 / 요청 중 다이얼로그 닫힘 시 엉뚱한 작성자 댓글 제거 / 늦은 페이지가 차단 댓글 복원. 웹 원본 `use-event-comments-pages`도 같은 구조(③)라 웹 환류 후보 |
diff --git a/docs/spec/MSG-570.md b/docs/spec/MSG-570.md
new file mode 100644
index 00000000..9a7bef33
--- /dev/null
+++ b/docs/spec/MSG-570.md
@@ -0,0 +1,124 @@
+# MSG-570: [모바일] 사용자 차단 — 타인 영상·댓글 ⋯ 메뉴 "사용자 차단" + 프로필 "차단한 사용자" 목록·해제
+
+## 기획 요약
+애플 심사 지침 1.2(UGC)가 요구하는 "다른 사용자 차단" 수단을 앱에 붙인다. 진입점 3곳(격자 상세 타인 영상 ⋯ 메뉴 · 재생 화면 ⋯ 메뉴 · 이벤트 댓글 길게 누르기)에서 같은 확인 다이얼로그를 거쳐 `POST /api/users/{userId}/block`을 부르고, 성공 시 그 사용자의 콘텐츠가 현재 목록에서 사라진다. 프로필 설정에 "차단한 사용자"(`/profile/blocks`) 화면을 신설해 `GET /api/users/me/blocks` 목록과 확인 없는 해제(`DELETE`)를 제공한다. 서버(MSG-569)는 BE `origin/develop`에 머지돼 있고(b6a99235), hey-api 생성물 재생성이 선행이다. 웹·packages 소스는 수정하지 않는다.
+
+## 코드베이스 실측 (STATUS.md + 소스)
+- 서버 계약(BE `origin/develop` 실측): `POST/DELETE /api/users/{userId}/block` → `SuccessResponse`(자기 자신 400+1430, 없는 사용자 404+1404, 재차단·미차단 해제는 멱등 200). `GET /api/users/me/blocks` → `BlockedUserResponseDto[]` `{userId, nickname, profileImageUrl|null, blockedAt}` 최신순, 페이지 없음. springdoc operationId = 메서드명 `block`·`unblock`·`getBlockedUsers`(기존 스냅샷과 충돌 없음) → 생성물 예상 이름 `blockMutation()`·`unblockMutation()`·`getBlockedUsersOptions()/QueryKey()`.
+- 작성자 식별 필드(BE 실측 — 이름이 DTO마다 다르다): `GridGlobalVideoResponseDto.userId` · `VideoPlaybackResponseDto.userId` · `EventVideoDetailResponseDto.uploaderId` · `EventLocationVideoResponseDto.uploaderId` · 댓글 `EventVideoCommentResponseDto.authorId`(이미 생성물에 있음). 내 영상 목록 `GridVideoResponseDto`에는 없다(필요 없음).
+- **현재 레포 생성물(`apps/web/src/shared/api/generated`, 스냅샷 2026-09-01 MSG-541)에는 block 경로·`userId` 필드가 없다.** 모바일은 웹 생성물을 barrel(`shared/api/sdk.ts`·`query-options.ts`)로 공유하므로 재생성은 `apps/web/openapi/api-docs.json` 갱신 + 루트 `pnpm openapi-ts`.
+- `/v3/api-docs`는 dev nginx basic auth 뒤에 있다(`https://api-dev.fillmap.kr/v3/api-docs` 401 실측). 계정은 BE `.claude/docs/deploy.md` "API 문서 사이트" 절 → `~/fillmap-aws-backup-personal/soma-secrets.env`의 `DOCS_BASIC_AUTH*`. 이 세션은 비밀 파일 접근이 차단돼 스냅샷을 못 받았다(리스크 1).
+- `getMe`(`UserProfileResponseDto`)에 **내 userId가 없다**(email·nickname·profileImageUrl·createdAt·locationConsent·role뿐, BE 최신도 동일). 내 댓글 판정은 닉네임 비교로 간다(D6).
+- 재생 화면(`video-playback/ui/video-player-screen.tsx`)에는 **⋯ 메뉴가 없다**(`VideoActionsMenu` 소비처는 도감 갤러리 카드·격자 영상 행 2곳뿐). 티켓의 "재생 화면의 같은 ⋯ 메뉴"는 신설 대상이다(A2).
+- 재사용 가능: `VideoActionsMenu`/`VideoMoreSheet`/`VideoMoreButton`(mine 분기), `guardMutate`+`VIDEO_MUTATION_KEYS` 관례, `useAutoDismissToast`·`ActionToast`, `ModalCard`(삭제·로그아웃·탈퇴 확인 관례), `ActionSheet`(ui-native), `Avatar`(`src`+`fallback` 이니셜), `formatKstDate`("YYYY.MM.DD"), `report-history` 4상태 화면 구조, `event-video-cache` seed 관례(`appendComment`), `useEventCommentsPages`.
+- 이벤트 상세 캐시는 **invalidate 금지**(MSG-562 — 재조회가 조회수를 올린다). 재생 `getPlayback`도 같은 성격(MSG-431).
+
+## 수용 기준
+| # | 기준 | 유형 | 검증 방법 |
+|---|------|------|----------|
+| 1 | hey-api 재생성 후 생성물에 `block`·`unblock`·`getBlockedUsers` 오퍼레이션과 `GridGlobalVideoResponseDto.userId`·`VideoPlaybackResponseDto.userId`·`EventVideoDetailResponseDto.uploaderId`·`EventLocationVideoResponseDto.uploaderId`가 존재하고 web·mobile `typecheck`가 통과한다 | 로직 | 게이트 + grep |
+| 2 | 격자 상세 타인 영상 행(`mine=false`)의 ⋯ 시트에 "신고하기" 아래 "사용자 차단" 행이 보이고, 내 영상 행·도감 갤러리 카드의 시트에는 보이지 않는다 | 화면 | 실기 |
+| 3 | "사용자 차단" 탭 → 확인 다이얼로그: 제목 `@닉네임 님을 차단할까요?`, 본문 "이 사용자의 영상과 댓글이 더 이상 보이지 않아요. 프로필 > 차단한 사용자에서 해제할 수 있어요", 버튼 [차단](danger)·[취소]. 취소·딤 탭·Android back은 요청 없이 닫힌다 | 화면 | 실기 |
+| 4 | [차단] → `POST /api/users/{userId}/block`이 행 작성자 `userId`를 경로로 1회 발사된다 | 로직 | vitest(MutationObserver) |
+| 5 | 차단 성공 → 다이얼로그가 닫히고 토스트 "차단했어요"가 뜨며, 격자 상세 목록에서 그 사용자의 영상이 재조회로 사라진다 | 화면 | 실기 |
+| 6 | 차단 성공 무효화 집합: `getGridGlobalVideos`·`getLocationVideos`(infinite)·`getMissionVideos`·`getComments`는 전 파라미터 무효화(활성 재조회), `getPlayback`·`getVideoDetail`은 `refetchType: "none"`(다음 마운트에서 재조회 — 조회수 부작용·재생 화면 pop 전 404 플래시 방지) | 로직 | vitest |
+| 7 | 차단 실패 → 다이얼로그가 유지된 채 오류 안내("차단하지 못했어요. 잠시 후 다시 시도해 주세요")가 뜨고 어떤 쿼리도 무효화되지 않는다 | 로직+화면 | vitest + 실기 |
+| 8 | 요청 진행 중 [차단]이 비활성이고, 같은 mutationKey의 요청이 in-flight면 재발사가 무시된다(`guardMutate`) | 로직 | vitest |
+| 9 | 재생 화면: 타인 영상(`mine=0`)일 때만 헤더 우측에 ⋯ 버튼이 있고, 같은 시트(신고하기·사용자 차단)가 열린다. 내 영상(`mine=1`)에는 ⋯가 없다. 차단 성공 시 이전 화면으로 돌아간다 | 화면 | 실기 |
+| 10 | 이벤트 영상 상세 시트의 타인 댓글 행을 길게 누르면 "사용자 차단" 1행 액션시트가 뜨고 기준 3~8과 같은 확인·요청 흐름을 탄다. 내 댓글(작성자 닉네임 = `getMe` 닉네임) 길게 누르기는 아무것도 띄우지 않는다 | 화면 | 실기 |
+| 11 | 댓글 경로 차단 성공 → 상세 캐시에서 그 `authorId`의 댓글이 제거되고(`removeCommentsByAuthor`, `commentCount` 불변 — 서버도 줄이지 않음) 이어받은 댓글 페이지가 리셋돼 목록에서 사라진다 | 로직+화면 | vitest + 실기 |
+| 12 | 프로필 설정 섹션에 "신고 관리" 바로 아래 "차단한 사용자" 행이 있고 탭하면 `/profile/blocks`로 이동한다. `profile/blocks`가 `PROTECTED_ROUTES`에 등재된다(app-entry L3 라우트 대조 테스트) | 화면+로직 | 실기 + vitest |
+| 13 | 목록은 `GET /api/users/me/blocks`로 채우고 서버 순서(최신순)를 유지한다. 행 = 아바타(`profileImageUrl`, 없으면 닉네임 첫 글자) · 닉네임 · 차단일 `YYYY.MM.DD`(KST) · [차단 해제] | 로직+화면 | vitest(row view) + 실기 |
+| 14 | [차단 해제] → 확인 없이 `DELETE /api/users/{userId}/block`이 발사되고, 성공 시 그 행이 목록 캐시에서 즉시 제거된다(`removeBlockedUser` seed, 재조회 없음) + 기준 6과 같은 무효화 | 로직+화면 | vitest + 실기 |
+| 15 | 해제 실패 → 행이 남고 오류 토스트("차단을 해제하지 못했어요. 잠시 후 다시 시도해 주세요")가 뜬다. 진행 중인 행의 버튼은 비활성 | 로직+화면 | vitest + 실기 |
+| 16 | 목록 4상태: 로딩 스피너 / 실패 "차단한 사용자를 불러오지 못했어요" + [다시 시도] / 빈 목록 "차단한 사용자가 없어요" / 목록. 판정은 순수 `resolveBlockListState`(실패 > 로딩 > 빈 > 목록) | 로직+화면 | vitest + 실기 |
+| 17 | 해제 후 격자 상세에 다시 들어가면 그 사용자의 영상이 다시 보인다(기준 6·14의 격자 전역 목록 무효화) | 화면 | 실기 |
+| 18 | `apps/web` **소스** diff 0(생성물 4파일·`openapi/api-docs.json` 스냅샷 제외), `packages/*` diff 0 | 감사 | git diff |
+
+**검증 프로파일**: 화면 — 신규 UI 3곳(확인 다이얼로그·재생 헤더 ⋯·차단 목록 화면)과 목록 소실이 시각 결과라 실기(3-B) 대상. Figma 시안은 없다(티켓 [참고]에 링크 없음, 신고 관리 화면과 같은 앱 관례). 실기 경로: 계정 A로 B 영상 차단 → 목록 소실 → 차단 목록 해제 → 재진입 복귀, 1회 + 스크린샷 ≤6장·15분.
+
+## 구현 계획
+- **브랜치**: `feat/MSG-570-mobile-user-block` (로컬·원격 모두 기존 브랜치 없음 — 실측)
+
+### 0. 선행 — hey-api 재생성 (기준 1)
+1. `apps/web`에서 `curl -sS -u "$DOCS_BASIC_AUTH" -o openapi/api-docs.json https://api-dev.fillmap.kr/v3/api-docs` (계정: BE deploy.md → `soma-secrets.env` `DOCS_BASIC_AUTH*`. 원문 그대로, 재정렬 금지 — `openapi-ts.config.ts` 절차)
+2. 루트 `pnpm openapi-ts` → `apps/web/src/shared/api/generated/*` 4파일 갱신
+3. `pnpm typecheck`(web+mobile). 2026-09-01 이후 BE 변경(MSG-594 애플 로그인 등)이 함께 들어오지만 additive라 웹 소스 무수정이어야 한다 — 어긋나면 리스크 1로 보고
+4. 생성물에 `blockMutation`·`unblockMutation`·`getBlockedUsersOptions`·`getBlockedUsersQueryKey`와 `userId`/`uploaderId` 필드가 있는지 grep으로 확정. 이름이 다르면 스펙의 이름을 생성물에 맞춘다(계약이 정본)
+
+### 1. `features/user-block/` 신설 (report-history와 같은 3층 구조)
+- `model/user-block.ts` (순수 + `user-block.test.ts`)
+ - `BlockTarget = { userId: number; nickname: string }` — 진입점 3곳이 넘기는 대상
+ - `blockConfirmTitle(nickname)` → `@{nickname} 님을 차단할까요?`, `BLOCK_CONFIRM_DESCRIPTION` 상수
+ - `resolveBlockListState({isPending,isError,items})` → `"loading"|"error"|"empty"|"list"`(report-history와 같은 우선순위)
+ - `toBlockedUserRowView(dto)` → `{ nickname, initial: nickname[0], avatarUrl: profileImageUrl ?? undefined, blockedAt: formatKstDate(blockedAt) }`
+ - `removeBlockedUser(list, userId)` — 해제 성공 seed용 순수 필터
+- `api/invalidate-blocked-content.ts` (순수 + test, `invalidate-video-queries` 패턴) — `invalidateAfterBlockChange(queryClient)`: `_id` 부분 키로 `getGridGlobalVideos`·`getLocationVideos`(infinite 포함)·`getMissionVideos`·`getComments` 무효화, `getPlayback`·`getVideoDetail`은 `{ refetchType: "none" }`(기준 6)
+- `api/user-block-mutations.ts` (옵션 팩토리 + `user-block-mutations.test.ts`, MutationObserver 구동)
+ - `USER_BLOCK_MUTATION_KEYS = { block: ["user-block","block"], unblock: ["user-block","unblock"] }`
+ - `blockUserMutationOptions({queryClient,onBlocked,onError})` — `blockMutation().mutationFn`, `path:{userId}`; onSuccess → `invalidateAfterBlockChange` + `onBlocked`
+ - `unblockUserMutationOptions({queryClient,onError})` — onSuccess → `getBlockedUsersQueryKey()` 캐시 `setQueryData(removeBlockedUser)` + `invalidateAfterBlockChange`
+- `api/use-user-block-mutations.ts` — 얇은 훅 2종, `guardMutate`(`video-actions/api/video-mutations` import — grid-detail→video-actions 선례)로 `mutate` 래핑·`mutateAsync` 은닉
+- `api/use-blocked-users-query.ts` — `getBlockedUsersOptions()` + `select: unwrapEnvelope`, `{items,isPending,isError,refetch}` 형태(report-history 훅과 동형이라 화면이 같은 스위치)
+- `ui/block-user-dialog.tsx` — `ModalCard`(title/description/[차단] `confirmVariant="danger"`/[취소], `confirmDisabled={submitting}`) + 실패 시 카드 안 `Toast`(report-modal 방식). props `{ target: BlockTarget | null; onClose; onBlocked }`. 뮤테이션·submitting·실패 문구를 **이 컴포넌트가 소유**한다 — 진입점 3곳이 동작을 공유하는 유일한 지점
+- `ui/blocked-user-row.tsx` — `Avatar size="md" src fallback={initial}` · 닉네임 · 차단일 · `Button text="차단 해제" variant="secondary" size="sm"`(해당 행 진행 중 `disabled`)
+- `ui/blocked-users-screen.tsx` — `AppHeader "차단한 사용자"` + 4상태 스위치(report-history-screen 미러, 상시 고지 카드는 없음 — 실 연동이므로) + 해제 실패 `ActionToast`
+- 라우트 `src/app/profile/blocks.tsx`(reports.tsx 미러) + `features/auth/model/app-entry.ts` `PROTECTED_ROUTES`에 `"profile/blocks"` 추가(L3 fs 대조 테스트가 강제)
+- `features/profile/ui/profile-screen.tsx` — 설정 섹션 "신고 관리" 아래 `SettingRow label="차단한 사용자" onPress={() => router.navigate("/profile/blocks")}`
+
+### 2. 영상 진입점 — `features/video-actions` (A2 채택 시)
+- `ui/video-more-sheet.tsx` — 타인 분기에 `ActionSheetItem label="사용자 차단" onPress={onBlock}` 추가(신고하기 아래, 아이콘 없음 유지)
+- `ui/video-actions-menu.tsx` — props `onReport` 제거 → `author?: BlockTarget`(타인 영상만) · `onBlocked?: () => void` 추가. **신고 흐름을 메뉴 안으로 흡수**: `ReportModal`(→ `video-actions/ui/report-modal.tsx`로 이동, grid-detail에서 제거) + `useReportVideo` + `reportFailureNotice` 분기 + 성공/중복 토스트(`ActionToast`). 차단: `BlockUserDialog` 배치, 성공 → `ActionToast "차단했어요"` + `onBlocked`
+- `features/grid-detail/ui/grid-detail-screen.tsx` — 신고 상태·모달·토스트·`useReportVideo` 삭제(순감), `GridVideoRow`에 `onReport` 대신 아무것도 안 넘김
+- `features/grid-detail/ui/grid-video-row.tsx` — `onReport` prop 제거, `author={row.author ?? undefined}`
+- `features/grid-detail/model/grid-videos.ts`(+test) — `GridVideoRow.author: BlockTarget | null`(전역 DTO `{userId, nickname}`, 내 영상 null)
+- `features/dex/ui/gallery-video-card.tsx` — `mine` 고정이라 무수정(props 제거로 typecheck만 확인)
+- `features/video-playback/ui/video-player-screen.tsx` — `!mine && playback`일 때 `AppHeader right={}` + `VideoActionsMenu mine={false} author={{userId: playback.userId, nickname: playback.nickname}} onBlocked={() => router.back()}`. `target`은 playback 응답 필드로 채운다(`gridId`는 타인 경로에서 미사용 — 응답에 없으면 `""` + 주석)
+
+### 3. 댓글 진입점 — `features/event`
+- `model/event-video-cache.ts`(+test) — `removeCommentsByAuthor(detail, authorId)`: `comments.comments` 필터, `commentCount` 불변(코덱스 P2 선례와 같은 결)
+- `api/use-event-comments-pages.ts` — 반환에 `reset()` 추가(`setExtra({videoId, pages: []})`, 3줄)
+- `ui/event-video-comment-row.tsx` — `Pressable onLongPress`(+`accessibilityActions=[{name:"longpress"}]`) 래핑, `onLongPress?: () => void`
+- `ui/event-video-sheet-content.tsx`(또는 `api/use-event-video-sheet.ts` 배선 훅) — `blockTarget` 상태, 타인 댓글 행 길게 누르기 → ui-native `ActionSheet` 1행 "사용자 차단" → `BlockUserDialog` → 성공 시 `setQueryData(removeCommentsByAuthor)` + `comments.reset()` + 시트 인라인 토스트 "차단했어요"(MSG-562 — 시트 안에서는 `ActionToast` Modal 금지). 내 댓글 판정: `comment.authorNickname === useProfileQuery().data?.nickname`(D6)
+
+- **재사용**: `ModalCard`·`ActionSheet(+Item)`·`Avatar`·`Button`·`AppHeader`·`Toast`(ui-native), `VideoActionsMenu`·`VideoMoreSheet`·`VideoMoreButton`·`ActionToast`·`useAutoDismissToast`·`guardMutate`(video-actions), `formatKstDate`·`unwrapEnvelope`·`gatedQueryStatus`(shared)
+- **신규 로직**: 위 1절 모델·옵션 팩토리·무효화 함수(`features/user-block`), `removeCommentsByAuthor`(`features/event/model`), `GridVideoRow.author`(`features/grid-detail/model`)
+- **라우트**: `/profile/blocks` 신설(보호 라우트), 프로필 설정 행 1개 추가
+- **승격 후보**: 없음 — 목록 행·확인 다이얼로그 모두 feature 로컬(신고 관리 행·삭제 확인과 같은 판단)
+- **테스트(test-first)**: `user-block.test.ts`(제목·row view·상태 판정·필터) · `user-block-mutations.test.ts`(POST/DELETE 경로·성공 seed·실패 무효화 없음·in-flight 무시) · `invalidate-blocked-content.test.ts`(무효화 집합 + refetchType none) · `event-video-cache.test.ts` 추가 케이스 · `grid-videos.test.ts` author 케이스 · `app-entry.test.ts`는 기존 그대로 통과해야 함
+
+## 추정 및 질문
+- **A1 (추정·권장)** 확인 UI는 티켓 표현 "확인 시트"와 달리 **`ModalCard` 중앙 카드**로 만든다 — 앱의 확인 관례(영상 삭제·로그아웃·계정 삭제)가 전부 ModalCard이고 Figma 시안이 없다. 문구·버튼은 티켓 그대로.
+- **A2 (추정·사용자 확인 필요)** 재생 화면에는 ⋯ 메뉴가 없어 신설한다. 권장안: 헤더 우측 ⋯(타인 영상만) + 격자 상세와 **같은 시트(신고하기·사용자 차단)**. 이를 위해 신고 모달·발사·토스트를 `VideoActionsMenu` 안으로 흡수한다(웹 `VideoMoreMenu`가 `ReportDialog`를 소유하는 구조와 동일, grid-detail-screen은 순감). 부수 효과: 격자 상세의 신고 성공 토스트가 `pointerEvents=none` 오버레이에서 `ActionToast`(탭 해제 Modal)로 바뀐다. **대안**: 재생 ⋯는 "사용자 차단"만 노출하고 신고 구조는 그대로 둔다(diff 최소, 화면 간 메뉴 불일치).
+- **A3 (추정)** 재생 화면에서 차단 성공 시 토스트 없이 즉시 `router.back()` — 티켓이 재생 화면엔 "이전 화면으로 돌아간다"만 요구. 화면이 pop되면 토스트 Modal도 함께 사라지므로 두지 않는다.
+- **A4 (추정)** 댓글 "내 댓글" 판정은 닉네임 비교 — `getMe`에 userId가 없다(BE 환류 후보: `UserProfileResponseDto.userId`). 동명 닉네임 충돌은 그 댓글에 차단 항목이 안 보이는 정도의 영향이고, 판정을 뚫어도 서버 400(1430)이 실패 토스트로 막는다.
+- **A5 (추정)** 댓글 경로 성공 처리는 상세 캐시 **seed + 페이지 리셋**이지 invalidate가 아니다(MSG-562 "상세 invalidate 금지" 준수). `commentCount`는 서버도 줄이지 않으므로 그대로 둔다(숫자와 목록이 어긋날 수 있음 — MSG-569 확정 사항).
+- **A6 (추정)** 무효화 집합에 티켓 열거 밖 `getMissionVideos`를 넣는다 — 서버가 필터하는 경로(MSG-569 6종)라 앱도 맞춘다. 격자 대표 영상·핫구역·탐색 집계는 티켓대로 제외.
+- **A7 (추정)** 해제 버튼은 `secondary` 소형 버튼, 진행 중 해당 행만 비활성. `guardMutate`가 같은 키 동시 발사를 막아 다른 행 연타는 무시된다(해제는 멱등이라 무해).
+- **A8 (추정)** 차단 목록 화면에 상시 고지 카드는 두지 않는다(신고 관리의 고지는 "서버 미연동" 사유였고 여기는 실연동).
+- **질문**: 없음. 사용자 결정이 필요한 것은 A2의 채택/대안 선택뿐.
+
+## 리스크
+1. **hey-api 재생성 선행이 이 세션에서 미완** — dev `/v3/api-docs`가 nginx basic auth 뒤라 계정(`soma-secrets.env` `DOCS_BASIC_AUTH*`)이 필요하고, 이 세션은 비밀 파일 접근이 차단됐다. 구현 첫 단계로 사용자(또는 허용된 세션)가 0절 명령을 실행해야 한다. dev 배포에 MSG-569가 반영됐는지도 재생성 결과(block 경로 존재)로 확정된다 — 없으면 BE dev 배포 대기.
+2. 재생성이 2026-09-01 이후 BE 변경 전체를 끌어온다(MSG-594 애플 로그인 등). additive 예상이나 웹 typecheck가 깨지면 이 티켓 범위 밖 — 별도 보고.
+3. `getMe`에 userId 부재(A4) — BE 환류 후보. 댓글 외 경로는 `mine` 판정이 이미 있어 영향 없음.
+4. 이벤트 상세 응답의 `uploaderId`는 이번에 쓰지 않는다(이벤트 영상 ⋯는 MSG-562 제외 범위 유지, 티켓도 댓글 행만 요구). 후속 티켓 후보.
+5. 실기 검증에 계정 2개 필요(B 영상은 웹에서 업로드 가능). 차단 관계에서 B 영상 재생은 404이므로 재생 화면 진입점 검증은 차단 **전** B 영상에서 수행한다.
+6. `EventVideoCommentRow` 길게 누르기는 시트 안 ScrollView 제스처와 겹칠 수 있다 — RN `Pressable onLongPress`는 스크롤과 공존하지만 실기에서 확인.
+- **Figma 오탐 방지**: Figma 시안 없음(티켓 [참고]에 링크 없음). 검증은 티켓 문구·앱 관례 기준. 티켓의 "확인 시트"는 ModalCard로 구현(A1) — 바텀시트가 아니어도 결함 아님. 차단 목록 행 레이아웃은 신고 관리 행 관례(카드형 행)의 자체 구성.
+
+## 승인 (2026-09-12)
+- 사용자 승인: 기존 앱 관례 조립(Figma 시안 없음, 신규 스타일 없음) + **A2 권장안 채택**(재생 ⋯ = 격자와 같은 시트, 신고 흐름을 `VideoActionsMenu`로 흡수). A1·A3~A8 기본값 확정.
+- 브랜치: `feat/MSG-570-mobile-user-block` (워크트리 `../FE-MSG-570`, origin/develop 4d0d698 기준)
+
+## 작업 로그
+- 선행: dev `/v3/api-docs`가 nginx basic auth(401)이고 `~/fillmap-aws-backup-personal/soma-secrets.env`가 이 기기에 없어, BE `origin/develop`(59fe4113) 임시 워크트리에서 `@SpringBootTest @AutoConfigureMockMvc` 테스트로 `/v3/api-docs`를 덤프해 재생성. MockMvc 산출물은 `servers[0].url`이 `http://localhost`라 `https://api.fillmap.kr`로 되돌린 뒤 `pnpm openapi-ts`(생성물 4파일 +380/-36).
+- 실측 동작: 빌드 리포트 `02_build_report.md` — 픽스처 4곳 필수 필드 추가(모바일 typecheck 10→0) → `features/user-block` model(test-first: 문구·row view·4상태·필터) → api(뮤테이션 옵션 팩토리 POST/DELETE·성공 seed·실패 무효화 없음·in-flight 무시, 무효화 집합 + `refetchType:"none"`) → ui 3종 + `/profile/blocks` → A2(`VideoActionsMenu` 신고 흡수, `report-modal.tsx` git mv, `GridVideoRow.author`) → 재생 헤더 ⋯ → 댓글 long-press(`removeCommentsByAuthor` + `reset()`). 모바일 vitest 210/1378, 웹 관련 53, 루트 typecheck·lint exit 0, check:duplication exit 0(베이스라인 접촉 22패밀리 제자리 교체).
+- 기준 18 예외: 재생성이 웹 테스트 픽스처 7곳(`mock-cells.ts`·`playback-fixture.ts`·`event-video-fixture.ts`·테스트 4곳)의 typecheck를 깨서 상수 id 필드만 추가. 웹 소스 동작 변경 0, `packages` diff 0.
+- 검증: `03_verify_report.md` — 실패 0. 통과 1·3·4·5·6·8·9·12·13·14·17·18. 로직 통과+화면 미실행: 2(내 행 — 계정에 내 영상 없음), 7·15(실패 토스트 — 서버 실패 재현 수단 없음), 16(로딩·실패 상태). 로직 통과+화면 확인불가: 10·11(진행 중 지역축제 0개라 댓글 시트 진입 불가 — 소스 대조 + vitest 대체). 실기 3-B 11분(emulator-5556 `FillMap_Pixel8_verify`, Metro 8082, 런북 1-D), RN 로그 에러 0, 스크린샷 6장. AppHeader ⋯ 가장자리 탭·타인 영상 visibility 조회 오류 토스트 모두 문제 없음.
+- 검토한 대안(기각 이유): 재생 ⋯에 차단만 노출·신고 구조 유지(화면 간 메뉴 불일치, 사용자 기각) · 재생 차단 토스트 생략 prop(pop되면 Modal도 사라져 결과 동일) · `resolveListState` 복제 유지+베이스라인 용서(신규 용서 금지) · user-block이 report-history 모델 import(타입 불일치) · 로컬 에러 블록 복제(중복 게이트가 클론으로 잡음 → `DexErrorState` 재사용) · 전체 `--write-baseline`(무관 항목까지 흔듦) · 웹 픽스처를 별도 웹 티켓으로 분리(7줄 때문에 develop CI가 빨개짐).
+- 실기 함정: 딥링크 `url=10.0.2.2:8082` 콜드 스타트로는 8081(타 세션 develop) 번들을 받았다 — 프로필에 "차단한 사용자" 행이 없어 발견. 해소는 dev 메뉴 "Change Bundle Location". 로그인 화면에서 `keyevent 82`는 카카오 버튼을 누르므로 다른 화면에서 열 것. `FillMap_Pixel8_verify` AVD는 번들 주소가 8082로 남아 있다.
+- 환류(지라 코멘트): `UserProfileResponseDto.userId` 부재 → 내 댓글 판정이 닉네임 비교(A4), BE 추가 시 id 비교로 · `DexErrorState` 사용처 4곳째 → ui-native 승격 검토 · 댓글 long-press 화면부(기준 10·11, 리스크 6 스크롤 공존) — 진행 중 이벤트 생기면 1회 실기.
+- codex 리뷰(push 전 게이트, `--scope branch`): P2 3건 전부 채택 — 차단 성공 시 차단 목록 무효화 · `onBlocked(userId)`로 제출값 전달(댓글 시트 레이스) · 댓글 페이지 `reset()` 세대 가드. RED 테스트 1건 추가(`user-block-mutations.test.ts`) 후 그린, 모바일 user-block·event 126 통과, 루트 typecheck·lint·format exit 0, react-doctor staged 100.
+- pre-commit: 첫 커밋은 develop 선재 지적(`grid-detail-screen.tsx` rn-scrollview-dynamic-padding)으로 `--no-verify`(DECISIONS 기록, 다른 게이트 수동 확인). 이 티켓이 새로 만든 재생 화면 복잡도 경고는 `PlaybackActions` 분리로 해소.
+- 재검증(7687ba5, 사용자 요청): 실기 3-B 1회 — 목록 캐시를 먼저 만든 뒤 차단 → 7초 뒤 재진입 시 새 행 즉시 표시(리뷰 P2 ① 회귀 통과), 차단 pop·소실·해제·복귀 통과, RN 로그 에러 0, 스크린샷 4장 추가. 댓글 경로는 여전히 진행 중 이벤트 0개라 확인불가. 함정: 같은 AVD 콜드 스타트는 번들 주소가 8081로 되돌아간다 — 매 실기 dev 메뉴 재설정 필요.
diff --git a/nose.baseline.json b/nose.baseline.json
index 1aedd8e1..9681fa69 100644
--- a/nose.baseline.json
+++ b/nose.baseline.json
@@ -264,26 +264,26 @@
]
},
{
- "id": "03b203220fafecbe",
+ "id": "925fe10582e49fac",
"note": "duplicated across 2 directories — extract a method from the repeated block",
"members": [
{
- "id": "98be5f0dd9d148c9",
- "source_digest": "fnv1a64:a2eff2b0e1c12a4c",
+ "id": "79217106c4287e4d",
+ "source_digest": "fnv1a64:88a82e91c3e11714",
"file": "apps/web/src/pages/map-home/ui/EventVideoMiniPanel.tsx",
"lang": "typescript",
- "start_line": 63,
- "end_line": 72,
+ "start_line": 107,
+ "end_line": 120,
"kind": "Block",
"is_fragment": false
},
{
- "id": "2be2e6fb9f1e7a58",
- "source_digest": "fnv1a64:bb15228197cdb645",
+ "id": "165f7469ad26177e",
+ "source_digest": "fnv1a64:d090030619f84df9",
"file": "apps/mobile/src/features/event/api/use-event-video-sheet.ts",
"lang": "typescript",
- "start_line": 82,
- "end_line": 89,
+ "start_line": 148,
+ "end_line": 157,
"kind": "Block",
"is_fragment": false
}
@@ -463,32 +463,6 @@
}
]
},
- {
- "id": "06a19f9fe1b0f613",
- "note": "local duplication — extract a method from the repeated block — high-parameter (20 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
- "members": [
- {
- "id": "756466cf04da8c4f",
- "source_digest": "fnv1a64:ccfa4b46bd669820",
- "file": "apps/web/src/pages/map-home/ui/event-archive-body.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 1,
- "end_line": 400,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "f4b0ae8a66eb2251",
- "source_digest": "fnv1a64:2104772191be3089",
- "file": "apps/web/src/pages/map-home/ui/event-capsule.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 1,
- "end_line": 211,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
{
"id": "073a5d3e499c1333",
"note": "consolidate `div` — 16 copies",
@@ -697,34 +671,6 @@
}
]
},
- {
- "id": "07dc5a9cfba7123e",
- "note": "consolidate `video` — 2 copies — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "ade3a15fec3232f7",
- "source_digest": "fnv1a64:c59829aadd56e0dc",
- "file": "apps/mobile/src/features/event/model/location-videos-query.parity.test.ts",
- "lang": "typescript",
- "start_line": 37,
- "end_line": 44,
- "kind": "Function",
- "name": "video",
- "is_fragment": false
- },
- {
- "id": "22df1c5d31e92604",
- "source_digest": "fnv1a64:42d8d4e7865f51d2",
- "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 28,
- "end_line": 35,
- "kind": "Function",
- "name": "video",
- "is_fragment": false
- }
- ]
- },
{
"id": "07ed7db899e8ae48",
"note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
@@ -1749,32 +1695,6 @@
}
]
},
- {
- "id": "14a3966fcea7691b",
- "note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "31c72b6ed999130c",
- "source_digest": "fnv1a64:e43b0ce5d834cf71",
- "file": "apps/mobile/src/test/event-video-fixture.ts",
- "lang": "typescript",
- "start_line": 40,
- "end_line": 46,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "1f5638df2e3f9670",
- "source_digest": "fnv1a64:e43b0ce5d834cf71",
- "file": "apps/web/src/test/event-video-fixture.ts",
- "lang": "typescript",
- "start_line": 39,
- "end_line": 45,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
{
"id": "15dedfd52e57ccb4",
"note": "duplicated across 2 directories — extract a method from the repeated block — high-parameter (6 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
@@ -1865,34 +1785,6 @@
}
]
},
- {
- "id": "179fa74176561309",
- "note": "duplicated across 2 directories — consolidate one shared type/API contract",
- "members": [
- {
- "id": "3bcd59d4dfbe0dbb",
- "source_digest": "fnv1a64:3bbd4dc0b9e62686",
- "file": "apps/web/src/shared/api/generated/types.gen.ts",
- "lang": "typescript",
- "start_line": 964,
- "end_line": 989,
- "kind": "Class",
- "name": "OrgAccountRequestCreateRequestDto",
- "is_fragment": false
- },
- {
- "id": "18b27e00a11b0c7b",
- "source_digest": "fnv1a64:6df97324c01445ac",
- "file": "apps/web/src/pages/org/account-request-form.ts",
- "lang": "typescript",
- "start_line": 34,
- "end_line": 42,
- "kind": "Class",
- "name": "AccountRequestDraft",
- "is_fragment": false
- }
- ]
- },
{
"id": "185813ba096c150d",
"note": "duplicated across 2 directories — extract a method from the repeated block",
@@ -2013,26 +1905,26 @@
]
},
{
- "id": "18e7cc031270f2a2",
+ "id": "3f7479982c51b693",
"note": "duplicated across 2 directories — extract a method from the repeated block",
"members": [
{
- "id": "17996fd10d7d1bca",
- "source_digest": "fnv1a64:4df925768641b412",
+ "id": "1fc0f7eca9eb99c2",
+ "source_digest": "fnv1a64:55d54072a208be4e",
"file": "apps/web/src/features/event/api/use-event-video-mutations.ts",
"lang": "typescript",
- "start_line": 108,
- "end_line": 132,
+ "start_line": 72,
+ "end_line": 88,
"kind": "Block",
"is_fragment": false
},
{
- "id": "e7e37b7d92273d1d",
- "source_digest": "fnv1a64:d5e839151ac87256",
+ "id": "deb9ce525f0e5f16",
+ "source_digest": "fnv1a64:621c94823b10d0b1",
"file": "apps/mobile/src/features/event/api/event-video-mutations.ts",
"lang": "typescript",
- "start_line": 120,
- "end_line": 135,
+ "start_line": 83,
+ "end_line": 97,
"kind": "Block",
"is_fragment": false
}
@@ -2208,34 +2100,6 @@
}
]
},
- {
- "id": "1d70edcd0b39ccd2",
- "note": "consolidate `view` — 2 copies",
- "members": [
- {
- "id": "7120eeaa0a73334e",
- "source_digest": "fnv1a64:e30a5e3000d0c053",
- "file": "apps/mobile/src/features/map-home/ui/course-spot-row.tsx",
- "lang": "typescript",
- "start_line": 67,
- "end_line": 80,
- "kind": "Block",
- "name": "view",
- "is_fragment": false
- },
- {
- "id": "2a3aa9ae62fd3761",
- "source_digest": "fnv1a64:13f3bec96e55ae61",
- "file": "apps/mobile/src/features/event/ui/event-video-comment-row.tsx",
- "lang": "typescript",
- "start_line": 20,
- "end_line": 30,
- "kind": "Block",
- "name": "view",
- "is_fragment": false
- }
- ]
- },
{
"id": "1da7ba178beabf44",
"note": "consolidate `RouteInputCardProps` — 2 copies",
@@ -2928,59 +2792,8 @@
]
},
{
- "id": "25600f71a3b93506",
- "note": "duplicated across 2 directories — extract a helper — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "d7b60e86a13944b1",
- "source_digest": "fnv1a64:fd13904823530ecd",
- "file": "apps/mobile/src/features/grid-detail/model/grid-videos.test.ts",
- "lang": "typescript",
- "start_line": 22,
- "end_line": 29,
- "kind": "Block",
- "is_fragment": true,
- "fragment_kind": "direct-return",
- "reason_code": "exact-direct-return"
- },
- {
- "id": "7d64590a0cf260e2",
- "source_digest": "fnv1a64:aaee7dd1522ba154",
- "file": "apps/web/src/features/map-home/model/use-grid-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 48,
- "end_line": 55,
- "kind": "Function",
- "name": "globalDto",
- "is_fragment": false
- },
- {
- "id": "080d5f04e2e6abd9",
- "source_digest": "fnv1a64:533ce7d5292b5976",
- "file": "apps/web/src/features/map-home/model/use-hot-region-summary.test.tsx",
- "lang": "typescript",
- "start_line": 27,
- "end_line": 34,
- "kind": "Function",
- "name": "globalVideo",
- "is_fragment": false
- },
- {
- "id": "5293ffae59742d07",
- "source_digest": "fnv1a64:f6b59f52dc3cb64d",
- "file": "apps/web/src/features/map-home/model/use-multi-grid-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 8,
- "end_line": 15,
- "kind": "Function",
- "name": "video",
- "is_fragment": false
- }
- ]
- },
- {
- "id": "2574d8b8a76a9c9d",
- "note": "repeated across 6 directories — extract a shared abstraction — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "id": "36c913bc72ca3903",
+ "note": "repeated across 7 directories — extract a shared abstraction — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
"members": [
{
"id": "9c0d0b029f2e8b9d",
@@ -3026,6 +2839,17 @@
"name": "mountReplaceHook",
"is_fragment": false
},
+ {
+ "id": "c94a4df27339165f",
+ "source_digest": "fnv1a64:b6ff2280908040a3",
+ "file": "apps/mobile/src/features/user-block/api/user-block-mutations.test.ts",
+ "lang": "typescript",
+ "start_line": 18,
+ "end_line": 30,
+ "kind": "Function",
+ "name": "loadModule",
+ "is_fragment": false
+ },
{
"id": "68784a5d0f688be6",
"source_digest": "fnv1a64:8fbcd81aecdffa7e",
@@ -5897,56 +5721,6 @@
}
]
},
- {
- "id": "4ab013f3059e3b7a",
- "note": "consolidate `pressable` — 4 copies",
- "members": [
- {
- "id": "aa5541873e321857",
- "source_digest": "fnv1a64:7d9b62c48e5f6496",
- "file": "apps/mobile/src/features/video-playback/ui/video-player-screen.tsx",
- "lang": "typescript",
- "start_line": 132,
- "end_line": 140,
- "kind": "Block",
- "name": "pressable",
- "is_fragment": false
- },
- {
- "id": "ba900f1ec9946c5f",
- "source_digest": "fnv1a64:25a509487e6cc39d",
- "file": "packages/ui-native/src/action-sheet.tsx",
- "lang": "typescript",
- "start_line": 56,
- "end_line": 64,
- "kind": "Block",
- "name": "pressable",
- "is_fragment": false
- },
- {
- "id": "0e9d74583278a3a5",
- "source_digest": "fnv1a64:63aef2d25bd339bb",
- "file": "packages/ui-native/src/modal-card.tsx",
- "lang": "typescript",
- "start_line": 86,
- "end_line": 94,
- "kind": "Block",
- "name": "pressable",
- "is_fragment": false
- },
- {
- "id": "19d1904fcad97b00",
- "source_digest": "fnv1a64:3c1322d76b72004f",
- "file": "packages/ui-native/src/bottom-sheet.tsx",
- "lang": "typescript",
- "start_line": 55,
- "end_line": 61,
- "kind": "Block",
- "name": "pressable",
- "is_fragment": false
- }
- ]
- },
{
"id": "4b5913958e5f0a5a",
"note": "duplicated across 2 directories — extract a method from the repeated block",
@@ -6759,8 +6533,8 @@
]
},
{
- "id": "5601cd2604addff6",
- "note": "repeated across 3 directories — extract a shared abstraction — high-parameter (25 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
+ "id": "788daea42e558530",
+ "note": "repeated across 4 directories — extract a shared abstraction — high-parameter (25 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
"members": [
{
"id": "0482def0cb695fb2",
@@ -6773,12 +6547,22 @@
"is_fragment": false
},
{
- "id": "de5c6d40b3e440e2",
- "source_digest": "fnv1a64:1ffb2e6001de69e3",
+ "id": "f3161644119e1f43",
+ "source_digest": "fnv1a64:a7c6466ca6f8f399",
"file": "apps/mobile/src/features/event/model/location-videos-query.parity.test.ts",
"lang": "typescript",
"start_line": 1,
- "end_line": 157,
+ "end_line": 158,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "3a1c661c0fd1306e",
+ "source_digest": "fnv1a64:7c2df62037d6aed8",
+ "file": "apps/mobile/src/features/search/model/zone-search.parity.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 103,
"kind": "Block",
"is_fragment": false
},
@@ -7778,8 +7562,8 @@
]
},
{
- "id": "6181a3ced99decfe",
- "note": "repeated across 3 directories — extract a shared abstraction",
+ "id": "16e47c4579b94dbc",
+ "note": "repeated across 4 directories — extract a shared abstraction",
"members": [
{
"id": "8619482d4158603e",
@@ -7810,9 +7594,19 @@
"end_line": 10,
"kind": "Block",
"is_fragment": false
- }
- ]
- },
+ },
+ {
+ "id": "3607b48c5edfaf4d",
+ "source_digest": "fnv1a64:4224699567028446",
+ "file": "apps/mobile/src/features/search/ui/search-screen.tsx",
+ "lang": "typescript",
+ "start_line": 2,
+ "end_line": 9,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
{
"id": "61aea978c7c5b886",
"note": "local duplication — extract a helper",
@@ -8017,26 +7811,26 @@
]
},
{
- "id": "64f235bdb6e20e60",
+ "id": "b29407bb5cc78aff",
"note": "duplicated across 2 directories — extract a method from the repeated block",
"members": [
{
- "id": "79217106c4287e4d",
- "source_digest": "fnv1a64:88a82e91c3e11714",
+ "id": "dd105c79ffb789a3",
+ "source_digest": "fnv1a64:efb74a500324466b",
"file": "apps/web/src/pages/map-home/ui/EventVideoMiniPanel.tsx",
"lang": "typescript",
- "start_line": 107,
- "end_line": 120,
+ "start_line": 82,
+ "end_line": 95,
"kind": "Block",
"is_fragment": false
},
{
- "id": "90be50fb58664262",
- "source_digest": "fnv1a64:d090030619f84df9",
+ "id": "2bfe605b73f7d199",
+ "source_digest": "fnv1a64:335a76e18c123da9",
"file": "apps/mobile/src/features/event/api/use-event-video-sheet.ts",
"lang": "typescript",
- "start_line": 102,
- "end_line": 111,
+ "start_line": 112,
+ "end_line": 120,
"kind": "Block",
"is_fragment": false
}
@@ -9032,32 +8826,6 @@
}
]
},
- {
- "id": "73bfeb98dc9f716f",
- "note": "duplicated across 2 directories — extract a method from the repeated block",
- "members": [
- {
- "id": "d9fe11f6bf27d91f",
- "source_digest": "fnv1a64:8edcb5adfbe502cd",
- "file": "apps/web/src/features/event/model/use-event-comments-pages.ts",
- "lang": "typescript",
- "start_line": 11,
- "end_line": 84,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "f7aa273791de9c75",
- "source_digest": "fnv1a64:bcf73e67bffc25da",
- "file": "apps/mobile/src/features/event/api/use-event-comments-pages.ts",
- "lang": "typescript",
- "start_line": 31,
- "end_line": 95,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
{
"id": "73dfc557be186df5",
"note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
@@ -9251,32 +9019,6 @@
}
]
},
- {
- "id": "763a6ce28634360b",
- "note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "4ebc6209d3cf78bd",
- "source_digest": "fnv1a64:b8773884418e0df0",
- "file": "apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 111,
- "end_line": 134,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "4678675de6924a73",
- "source_digest": "fnv1a64:14a4e35b40cda9e8",
- "file": "apps/web/src/pages/map-home/ui/region-list-view.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 94,
- "end_line": 103,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
{
"id": "769c4f93e6cf4917",
"note": "repeated across 5 directories — extract a shared abstraction",
@@ -9572,26 +9314,26 @@
]
},
{
- "id": "78b154684a5df34b",
+ "id": "b0ba98f57cdd64af",
"note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
"members": [
{
- "id": "e8a3a9467b4c18f7",
+ "id": "eafc217fe829b653",
"source_digest": "fnv1a64:594064226760ba65",
"file": "apps/mobile/src/features/grid-detail/model/grid-videos.test.ts",
"lang": "typescript",
- "start_line": 56,
- "end_line": 66,
+ "start_line": 59,
+ "end_line": 69,
"kind": "Block",
"is_fragment": false
},
{
- "id": "09c8a0287ffd9689",
+ "id": "685c671e13767e99",
"source_digest": "fnv1a64:6f49acbd02649142",
"file": "apps/mobile/src/features/grid-detail/model/grid-videos.test.ts",
"lang": "typescript",
- "start_line": 40,
- "end_line": 48,
+ "start_line": 43,
+ "end_line": 51,
"kind": "Block",
"is_fragment": false
}
@@ -9735,57 +9477,6 @@
}
]
},
- {
- "id": "7a69afdcefbbc948",
- "note": "repeated across 4 directories — extract a shared abstraction — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "e5792c44e6811144",
- "source_digest": "fnv1a64:181266e0b677c31d",
- "file": "apps/mobile/src/features/profile/model/notification-toggle.test.ts",
- "lang": "typescript",
- "start_line": 18,
- "end_line": 24,
- "kind": "Function",
- "name": "envelope",
- "is_fragment": false
- },
- {
- "id": "0860a8ed256d440e",
- "source_digest": "fnv1a64:da6774137205c02f",
- "file": "apps/web/src/features/admin-events/api/use-unpublish-event.test.tsx",
- "lang": "typescript",
- "start_line": 16,
- "end_line": 20,
- "kind": "Block",
- "is_fragment": true,
- "fragment_kind": "direct-return",
- "reason_code": "exact-direct-return"
- },
- {
- "id": "b4f2b073b542f6a6",
- "source_digest": "fnv1a64:e1677d2fde494d1b",
- "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 56,
- "end_line": 56,
- "kind": "Function",
- "name": "envelope",
- "is_fragment": false
- },
- {
- "id": "c5fadab270263b49",
- "source_digest": "fnv1a64:e1677d2fde494d1b",
- "file": "apps/web/src/features/region/model/use-explore-regions-query.test.tsx",
- "lang": "typescript",
- "start_line": 53,
- "end_line": 53,
- "kind": "Function",
- "name": "envelope",
- "is_fragment": false
- }
- ]
- },
{
"id": "7a70acdf3422ae14",
"note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
@@ -10627,7 +10318,7 @@
]
},
{
- "id": "7ffdcfdceed6aba7",
+ "id": "bb566060a339f30e",
"note": "duplicated across 2 directories — extract a method from the repeated block",
"members": [
{
@@ -10641,12 +10332,12 @@
"is_fragment": false
},
{
- "id": "5a36acdadfe05b8a",
- "source_digest": "fnv1a64:5d8609d496dad5b4",
+ "id": "02354c2de9cf664d",
+ "source_digest": "fnv1a64:4b2812f9fd49114c",
"file": "apps/mobile/src/features/event/api/use-event-video-sheet.ts",
"lang": "typescript",
- "start_line": 108,
- "end_line": 129,
+ "start_line": 154,
+ "end_line": 186,
"kind": "Block",
"is_fragment": false
}
@@ -10930,32 +10621,6 @@
}
]
},
- {
- "id": "83a5a56196af445c",
- "note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "ef294775cc873f60",
- "source_digest": "fnv1a64:a59ea789f633665d",
- "file": "apps/web/src/pages/map-home/ui/event-archive-body.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 369,
- "end_line": 374,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "778d4cf3ace05a35",
- "source_digest": "fnv1a64:51c83aca72a3ada2",
- "file": "apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 214,
- "end_line": 219,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
{
"id": "83daa3a40c2f28cf",
"note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
@@ -11088,42 +10753,6 @@
}
]
},
- {
- "id": "86c6bbdb8753ab2f",
- "note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "6579ee889699da8c",
- "source_digest": "fnv1a64:27a778a55155252e",
- "file": "apps/web/src/features/region/model/use-explore-regions-query.test.tsx",
- "lang": "typescript",
- "start_line": 111,
- "end_line": 121,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "9f18e6ee2bc4ec4d",
- "source_digest": "fnv1a64:f7c26ea97f99e084",
- "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 123,
- "end_line": 129,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "25318000d9c304ee",
- "source_digest": "fnv1a64:cba31d48d0813332",
- "file": "apps/web/src/features/region/model/use-explore-regions-query.test.tsx",
- "lang": "typescript",
- "start_line": 152,
- "end_line": 156,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
{
"id": "86d80858d274caf0",
"note": "repeated across 3 directories — extract a shared abstraction",
@@ -11213,189 +10842,73 @@
]
},
{
- "id": "87f3dbce61fa5508",
- "note": "repeated across 6 directories — extract a shared abstraction — high-parameter (18 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
+ "id": "30affa09de9c90ca",
+ "note": "consolidate `view` — 22 copies",
"members": [
{
- "id": "83f065fc67a38305",
- "source_digest": "fnv1a64:4bda003a9928b261",
- "file": "apps/mobile/src/features/ai-route/api/route-recommend-mutation.test.ts",
+ "id": "39ee78a845f37eed",
+ "source_digest": "fnv1a64:e50ad16d25809228",
+ "file": "apps/mobile/src/features/map-home/ui/mission-status-badge.tsx",
"lang": "typescript",
- "start_line": 1,
- "end_line": 362,
+ "start_line": 36,
+ "end_line": 58,
"kind": "Block",
+ "name": "view",
"is_fragment": false
},
{
- "id": "00e80863d01d6dbe",
- "source_digest": "fnv1a64:b562239791050fbd",
- "file": "apps/mobile/src/features/video-actions/api/video-mutations.test.ts",
+ "id": "5b5ce040f646e059",
+ "source_digest": "fnv1a64:c3e76768bd19f6ca",
+ "file": "apps/mobile/src/features/event/ui/event-status-badge.tsx",
"lang": "typescript",
- "start_line": 1,
- "end_line": 356,
+ "start_line": 16,
+ "end_line": 30,
"kind": "Block",
+ "name": "view",
"is_fragment": false
},
{
- "id": "c9b9884efc6a29b6",
- "source_digest": "fnv1a64:22968ef0cd99615f",
- "file": "apps/mobile/src/features/event/api/event-video-mutations.test.ts",
+ "id": "5a5d3b4d3265ca3d",
+ "source_digest": "fnv1a64:d71428976be88090",
+ "file": "apps/mobile/src/features/map-home/ui/course-spot-row.tsx",
"lang": "typescript",
- "start_line": 1,
- "end_line": 243,
+ "start_line": 51,
+ "end_line": 65,
"kind": "Block",
+ "name": "view",
"is_fragment": false
},
{
- "id": "550baae39321331b",
- "source_digest": "fnv1a64:1491ab04aa990de3",
- "file": "apps/mobile/src/features/profile/api/use-notification-toggle.test.ts",
+ "id": "f4b9bad618dd1690",
+ "source_digest": "fnv1a64:d69e565fce14b4a2",
+ "file": "apps/mobile/src/features/ai-route/ui/route-stop-card.tsx",
"lang": "typescript",
- "start_line": 1,
- "end_line": 210,
+ "start_line": 63,
+ "end_line": 74,
"kind": "Block",
+ "name": "view",
"is_fragment": false
},
{
- "id": "37c7795192016805",
- "source_digest": "fnv1a64:a45fab8a87ce41fe",
- "file": "apps/mobile/src/features/profile/api/marketing-consent-mutation.test.ts",
+ "id": "e5c4450f021a0dd2",
+ "source_digest": "fnv1a64:3ac1d3b5ffc868ce",
+ "file": "apps/mobile/src/features/map-home/ui/grid-detail-sheet-content.tsx",
"lang": "typescript",
- "start_line": 1,
- "end_line": 187,
+ "start_line": 87,
+ "end_line": 97,
"kind": "Block",
+ "name": "view",
"is_fragment": false
},
{
- "id": "0a41e871b8cf6eff",
- "source_digest": "fnv1a64:29dac2a22dff6e74",
- "file": "apps/mobile/src/features/profile/api/use-profile-image-upload.test.ts",
+ "id": "bcaa2827e4ded075",
+ "source_digest": "fnv1a64:013c4d36ed7e1761",
+ "file": "apps/mobile/src/features/dex/ui/featured-profile-preview.tsx",
"lang": "typescript",
- "start_line": 1,
- "end_line": 175,
+ "start_line": 44,
+ "end_line": 51,
"kind": "Block",
- "is_fragment": false
- },
- {
- "id": "977c30de5383937e",
- "source_digest": "fnv1a64:f203380283efb929",
- "file": "apps/mobile/src/features/auth/api/kakao-login-mutation.test.ts",
- "lang": "typescript",
- "start_line": 1,
- "end_line": 173,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "aa4c9e9a9c09a61d",
- "source_digest": "fnv1a64:a566aabfdf9fc714",
- "file": "apps/mobile/src/features/profile/api/use-remove-profile-image.test.ts",
- "lang": "typescript",
- "start_line": 1,
- "end_line": 120,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "5051f2c3798f50eb",
- "source_digest": "fnv1a64:82442ccd5228876f",
- "file": "apps/mobile/src/features/profile/api/use-update-nickname.test.ts",
- "lang": "typescript",
- "start_line": 1,
- "end_line": 108,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "6bffc25be1241e61",
- "source_digest": "fnv1a64:a47fbf3e929ad427",
- "file": "apps/mobile/src/features/dex/api/use-badge-mutations.test.ts",
- "lang": "typescript",
- "start_line": 1,
- "end_line": 104,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "f1ce9d548edea18d",
- "source_digest": "fnv1a64:af8d3fa405718272",
- "file": "apps/mobile/src/features/profile/api/delete-account-mutation.test.ts",
- "lang": "typescript",
- "start_line": 1,
- "end_line": 95,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
- {
- "id": "d88887890e1aa5f6",
- "note": "consolidate `view` — 22 copies",
- "members": [
- {
- "id": "39ee78a845f37eed",
- "source_digest": "fnv1a64:e50ad16d25809228",
- "file": "apps/mobile/src/features/map-home/ui/mission-status-badge.tsx",
- "lang": "typescript",
- "start_line": 36,
- "end_line": 58,
- "kind": "Block",
- "name": "view",
- "is_fragment": false
- },
- {
- "id": "5b5ce040f646e059",
- "source_digest": "fnv1a64:c3e76768bd19f6ca",
- "file": "apps/mobile/src/features/event/ui/event-status-badge.tsx",
- "lang": "typescript",
- "start_line": 16,
- "end_line": 30,
- "kind": "Block",
- "name": "view",
- "is_fragment": false
- },
- {
- "id": "5a5d3b4d3265ca3d",
- "source_digest": "fnv1a64:d71428976be88090",
- "file": "apps/mobile/src/features/map-home/ui/course-spot-row.tsx",
- "lang": "typescript",
- "start_line": 51,
- "end_line": 65,
- "kind": "Block",
- "name": "view",
- "is_fragment": false
- },
- {
- "id": "f4b9bad618dd1690",
- "source_digest": "fnv1a64:d69e565fce14b4a2",
- "file": "apps/mobile/src/features/ai-route/ui/route-stop-card.tsx",
- "lang": "typescript",
- "start_line": 63,
- "end_line": 74,
- "kind": "Block",
- "name": "view",
- "is_fragment": false
- },
- {
- "id": "e5c4450f021a0dd2",
- "source_digest": "fnv1a64:3ac1d3b5ffc868ce",
- "file": "apps/mobile/src/features/map-home/ui/grid-detail-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 87,
- "end_line": 97,
- "kind": "Block",
- "name": "view",
- "is_fragment": false
- },
- {
- "id": "bcaa2827e4ded075",
- "source_digest": "fnv1a64:013c4d36ed7e1761",
- "file": "apps/mobile/src/features/dex/ui/featured-profile-preview.tsx",
- "lang": "typescript",
- "start_line": 44,
- "end_line": 51,
- "kind": "Block",
- "name": "view",
+ "name": "view",
"is_fragment": false
},
{
@@ -11476,12 +10989,12 @@
"is_fragment": false
},
{
- "id": "5d83f92eba80a047",
+ "id": "004d1bd3cf337313",
"source_digest": "fnv1a64:375873ad3f9e31b6",
"file": "apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx",
"lang": "typescript",
- "start_line": 221,
- "end_line": 225,
+ "start_line": 180,
+ "end_line": 184,
"kind": "Block",
"name": "view",
"is_fragment": false
@@ -12757,7 +12270,7 @@
]
},
{
- "id": "92407e4d9d0cbaed",
+ "id": "e7cbfe6323f91a8d",
"note": "consolidate `view` — 3 copies",
"members": [
{
@@ -12783,12 +12296,12 @@
"is_fragment": false
},
{
- "id": "ac3444b8098099ea",
+ "id": "f9d9b04ccd21d00a",
"source_digest": "fnv1a64:ec90f08afbe36460",
"file": "apps/mobile/src/features/event/ui/event-video-sheet-content.tsx",
"lang": "typescript",
- "start_line": 90,
- "end_line": 94,
+ "start_line": 109,
+ "end_line": 113,
"kind": "Block",
"name": "view",
"is_fragment": false
@@ -13797,62 +13310,6 @@
}
]
},
- {
- "id": "9c02ac5acae46a9c",
- "note": "repeated across 3 directories — extract a shared abstraction — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "1067492f38a33879",
- "source_digest": "fnv1a64:b0ec70cae3c3a183",
- "file": "apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 112,
- "end_line": 125,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "0e2d41613024cc33",
- "source_digest": "fnv1a64:5d27c890cccaeab2",
- "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 156,
- "end_line": 164,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "559b3ceb1ba9f746",
- "source_digest": "fnv1a64:353bb3b5c30ff40b",
- "file": "apps/web/src/features/region/model/use-explore-regions-query.test.tsx",
- "lang": "typescript",
- "start_line": 141,
- "end_line": 149,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "73c748a723e34c9a",
- "source_digest": "fnv1a64:7bea79907dba0f84",
- "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 49,
- "end_line": 54,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "7cca1263a6030e08",
- "source_digest": "fnv1a64:68d6e54c2fcfab1f",
- "file": "apps/web/src/pages/map-home/ui/region-list-view.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 80,
- "end_line": 84,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
{
"id": "9c1ee8b77252c1de",
"note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
@@ -14040,26 +13497,26 @@
]
},
{
- "id": "9e55aaa0d4714e3b",
+ "id": "c235bbbd0142fcd4",
"note": "duplicated across 2 directories — extract a method from the repeated block",
"members": [
{
- "id": "dd105c79ffb789a3",
- "source_digest": "fnv1a64:efb74a500324466b",
+ "id": "98be5f0dd9d148c9",
+ "source_digest": "fnv1a64:a2eff2b0e1c12a4c",
"file": "apps/web/src/pages/map-home/ui/EventVideoMiniPanel.tsx",
"lang": "typescript",
- "start_line": 82,
- "end_line": 95,
+ "start_line": 63,
+ "end_line": 72,
"kind": "Block",
"is_fragment": false
},
{
- "id": "63cc34f089d1a81d",
- "source_digest": "fnv1a64:335a76e18c123da9",
+ "id": "854f4a07f11fb70a",
+ "source_digest": "fnv1a64:bb15228197cdb645",
"file": "apps/mobile/src/features/event/api/use-event-video-sheet.ts",
"lang": "typescript",
- "start_line": 89,
- "end_line": 97,
+ "start_line": 105,
+ "end_line": 112,
"kind": "Block",
"is_fragment": false
}
@@ -14091,67 +13548,6 @@
}
]
},
- {
- "id": "9f6b09e7221954c4",
- "note": "repeated across 4 directories — extract a shared abstraction",
- "members": [
- {
- "id": "2fc03103b8c2381f",
- "source_digest": "fnv1a64:e448a445e10f69a1",
- "file": "apps/web/src/pages/map-home/ui/CourseDetailPanel.tsx",
- "lang": "typescript",
- "start_line": 87,
- "end_line": 94,
- "kind": "Block",
- "name": "p",
- "is_fragment": false
- },
- {
- "id": "18e164e603efe6c2",
- "source_digest": "fnv1a64:716cb7839c75e2bc",
- "file": "apps/mobile/src/features/video-playback/ui/video-player-screen.tsx",
- "lang": "typescript",
- "start_line": 123,
- "end_line": 126,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "a82ad40264922171",
- "source_digest": "fnv1a64:92758f1ee063a9c5",
- "file": "apps/mobile/src/features/map-home/ui/course-detail-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 86,
- "end_line": 88,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "fed9ce0b13b70a5c",
- "source_digest": "fnv1a64:d424320e2c469200",
- "file": "apps/mobile/src/features/map-home/ui/hot-region-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 77,
- "end_line": 79,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "cef3f9c30ce393a6",
- "source_digest": "fnv1a64:c50650cce47d2b3f",
- "file": "apps/web/src/pages/org/ui/AreaUsageCard.tsx",
- "lang": "typescript",
- "start_line": 41,
- "end_line": 43,
- "kind": "Block",
- "name": "p",
- "is_fragment": false
- }
- ]
- },
{
"id": "a03aef6b71f986fe",
"note": "duplicated across 2 directories — extract a method from the repeated block — high-parameter (10 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
@@ -15025,36 +14421,8 @@
]
},
{
- "id": "ac3b6bb901c6536a",
- "note": "consolidate `CellSeed` — 2 copies",
- "members": [
- {
- "id": "f048dd783e802282",
- "source_digest": "fnv1a64:3337121a3a703e12",
- "file": "apps/web/src/entities/cell/model/mock-cells.ts",
- "lang": "typescript",
- "start_line": 84,
- "end_line": 98,
- "kind": "Class",
- "name": "CellSeed",
- "is_fragment": false
- },
- {
- "id": "a09af7b7ef17119d",
- "source_digest": "fnv1a64:6b5a41f0d3cf9452",
- "file": "apps/mobile/src/features/grid-detail/model/mock-cell-details.ts",
- "lang": "typescript",
- "start_line": 64,
- "end_line": 75,
- "kind": "Class",
- "name": "CellSeed",
- "is_fragment": false
- }
- ]
- },
- {
- "id": "acbaf70d027aaf4d",
- "note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "id": "acbaf70d027aaf4d",
+ "note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
"members": [
{
"id": "ea04423970d7d9ac",
@@ -15764,34 +15132,6 @@
}
]
},
- {
- "id": "b2c74c06b501bca2",
- "note": "duplicated across 2 directories — extract a helper",
- "members": [
- {
- "id": "d701765da46ccda9",
- "source_digest": "fnv1a64:f3dc21d104cc048d",
- "file": "apps/mobile/src/features/profile/model/profile-format.ts",
- "lang": "typescript",
- "start_line": 18,
- "end_line": 18,
- "kind": "Function",
- "name": "formatJoinedDate",
- "is_fragment": false
- },
- {
- "id": "b9908eb894ffd6ae",
- "source_digest": "fnv1a64:ba48846dfb6087f6",
- "file": "apps/mobile/src/features/report-history/model/report-history.ts",
- "lang": "typescript",
- "start_line": 102,
- "end_line": 102,
- "kind": "Function",
- "name": "formatReportedAt",
- "is_fragment": false
- }
- ]
- },
{
"id": "b35337088186283f",
"note": "consolidate `p` — 6 copies",
@@ -17559,183 +16899,6 @@
}
]
},
- {
- "id": "c9dec770586a0180",
- "note": "consolidate `text` — 13 copies",
- "members": [
- {
- "id": "39d037a43fe5a862",
- "source_digest": "fnv1a64:0af25d6ebf680b2b",
- "file": "apps/mobile/src/app/dev/api-smoke.tsx",
- "lang": "typescript",
- "start_line": 129,
- "end_line": 131,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "f9bd650017484b08",
- "source_digest": "fnv1a64:6638c59c4b57ea45",
- "file": "apps/mobile/src/features/event/ui/event-overview-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 59,
- "end_line": 61,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "96aa8ecfbe7beb3c",
- "source_digest": "fnv1a64:4e125af0a77491ef",
- "file": "apps/mobile/src/features/event/ui/event-overview-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 76,
- "end_line": 78,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "3cd5e8c5b425656e",
- "source_digest": "fnv1a64:dfa578ff0cbd7a89",
- "file": "apps/mobile/src/features/event/ui/event-video-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 91,
- "end_line": 93,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "63d14d6f529e8db1",
- "source_digest": "fnv1a64:3230166a7f6d487c",
- "file": "apps/mobile/src/features/map-home/ui/course-detail-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 106,
- "end_line": 108,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "b9bf9db80581ab4d",
- "source_digest": "fnv1a64:32cfb99b28cd1753",
- "file": "apps/mobile/src/features/map-home/ui/course-detail-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 109,
- "end_line": 111,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "05314d051a82f78c",
- "source_digest": "fnv1a64:3948dfdf7bafa22f",
- "file": "apps/mobile/src/features/map-home/ui/grid-detail-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 107,
- "end_line": 109,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "67d9652f93c26bd6",
- "source_digest": "fnv1a64:e4610cb0f72cee9b",
- "file": "apps/mobile/src/features/map-home/ui/hourly-bars.tsx",
- "lang": "typescript",
- "start_line": 24,
- "end_line": 26,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "49199f5fa245b562",
- "source_digest": "fnv1a64:97ea97f1176c5669",
- "file": "apps/mobile/src/features/map-home/ui/mission-detail-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 87,
- "end_line": 89,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "c728ce3346aae007",
- "source_digest": "fnv1a64:65efa12a318acc5a",
- "file": "apps/mobile/src/features/map-home/ui/region-list-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 72,
- "end_line": 74,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "9233e5ed1d7dc62a",
- "source_digest": "fnv1a64:bd449dc420f45d9a",
- "file": "packages/ui-native/src/segmented-progress.stories.tsx",
- "lang": "typescript",
- "start_line": 30,
- "end_line": 32,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "c2d44b38e05554c4",
- "source_digest": "fnv1a64:a00a2c38f82daee8",
- "file": "apps/mobile/src/features/event/ui/event-badge-header.tsx",
- "lang": "typescript",
- "start_line": 16,
- "end_line": 16,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "b6148ec8de7c389b",
- "source_digest": "fnv1a64:a00a2c38f82daee8",
- "file": "apps/mobile/src/features/map-home/ui/theme-badge-header.tsx",
- "lang": "typescript",
- "start_line": 25,
- "end_line": 25,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- }
- ]
- },
- {
- "id": "c9e705ff77514706",
- "note": "local duplication — extract a helper — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "dd4324e0ca6f630f",
- "source_digest": "fnv1a64:62a426074cc64acb",
- "file": "apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 31,
- "end_line": 42,
- "kind": "Function",
- "name": "video",
- "is_fragment": false
- },
- {
- "id": "5b4908dc4528ff84",
- "source_digest": "fnv1a64:6a4b6626521e3d09",
- "file": "apps/web/src/pages/map-home/ui/event-archive-body.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 218,
- "end_line": 225,
- "kind": "Function",
- "name": "archiveVideo",
- "is_fragment": false
- }
- ]
- },
{
"id": "6ae08906ebf5d550",
"note": "consolidate `view` — 2 copies",
@@ -17942,42 +17105,6 @@
}
]
},
- {
- "id": "cc447ef2efa71647",
- "note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "2c12c9b8419c0d12",
- "source_digest": "fnv1a64:a1eaa5e8f53daaa5",
- "file": "apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx",
- "lang": "typescript",
- "start_line": 150,
- "end_line": 170,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "700ff97188817680",
- "source_digest": "fnv1a64:cf691c64394bd0e6",
- "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 133,
- "end_line": 152,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "69a42a60ecd8d4a5",
- "source_digest": "fnv1a64:570a678b8441030a",
- "file": "apps/web/src/features/event/model/use-event-video-detail-query.test.tsx",
- "lang": "typescript",
- "start_line": 44,
- "end_line": 62,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
{
"id": "cc7131b8acac25ab",
"note": "repeated across 3 directories — extract a shared abstraction",
@@ -18017,34 +17144,6 @@
}
]
},
- {
- "id": "cce943b8497fb4dc",
- "note": "duplicated across 2 directories — consolidate one shared type/API contract",
- "members": [
- {
- "id": "fe4284beb2d23714",
- "source_digest": "fnv1a64:5ec8f7d1a6c032d6",
- "file": "apps/web/src/shared/api/generated/types.gen.ts",
- "lang": "typescript",
- "start_line": 3940,
- "end_line": 3973,
- "kind": "Class",
- "name": "RegionVideoResponseDto",
- "is_fragment": false
- },
- {
- "id": "c22c4838482876fd",
- "source_digest": "fnv1a64:7af7a3bbd9dd8a20",
- "file": "apps/web/src/features/video-actions/model/video-menu.ts",
- "lang": "typescript",
- "start_line": 25,
- "end_line": 35,
- "kind": "Class",
- "name": "VideoActionTarget",
- "is_fragment": false
- }
- ]
- },
{
"id": "cd4558398ca0d507",
"note": "consolidate `h4` — 2 copies",
@@ -19538,32 +18637,6 @@
}
]
},
- {
- "id": "db978989b15dc38b",
- "note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "07902b53e24fb9c7",
- "source_digest": "fnv1a64:60108eea9e1f56d2",
- "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 121,
- "end_line": 129,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "aeaec132e8dd500d",
- "source_digest": "fnv1a64:96698107f3bb8805",
- "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 167,
- "end_line": 173,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
{
"id": "dc02b06a94fec4d7",
"note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
@@ -20885,7 +19958,7 @@
]
},
{
- "id": "0a81d2c2cb7dd9ae",
+ "id": "200e5b4202b09b1c",
"note": "consolidate `view` — 14 copies",
"members": [
{
@@ -21010,12 +20083,12 @@
"is_fragment": false
},
{
- "id": "bcc710a592cb4658",
+ "id": "c72a0c323fd97cba",
"source_digest": "fnv1a64:53c2fc3fc37da918",
"file": "apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx",
"lang": "typescript",
- "start_line": 33,
- "end_line": 36,
+ "start_line": 26,
+ "end_line": 29,
"kind": "Block",
"name": "view",
"is_fragment": false
@@ -21563,26 +20636,26 @@
]
},
{
- "id": "ea4817d9c3fdafe7",
+ "id": "c12aa3b6fa9ae078",
"note": "duplicated across 2 directories — extract a method from the repeated block",
"members": [
{
- "id": "1fc0f7eca9eb99c2",
- "source_digest": "fnv1a64:55d54072a208be4e",
+ "id": "17996fd10d7d1bca",
+ "source_digest": "fnv1a64:4df925768641b412",
"file": "apps/web/src/features/event/api/use-event-video-mutations.ts",
"lang": "typescript",
- "start_line": 72,
- "end_line": 88,
+ "start_line": 108,
+ "end_line": 132,
"kind": "Block",
"is_fragment": false
},
{
- "id": "957756b435cad21a",
- "source_digest": "fnv1a64:621c94823b10d0b1",
+ "id": "9c0a554ed0daf763",
+ "source_digest": "fnv1a64:d5e839151ac87256",
"file": "apps/mobile/src/features/event/api/event-video-mutations.ts",
"lang": "typescript",
- "start_line": 82,
- "end_line": 96,
+ "start_line": 121,
+ "end_line": 136,
"kind": "Block",
"is_fragment": false
}
@@ -21955,7 +21028,7 @@
]
},
{
- "id": "f040e89d31ea71a4",
+ "id": "c3fd0d4580a93751",
"note": "consolidate `pressable` — 2 copies",
"members": [
{
@@ -21970,12 +21043,12 @@
"is_fragment": false
},
{
- "id": "56bf05952671849d",
+ "id": "8116c8f65bb7d578",
"source_digest": "fnv1a64:c73bb7bc144deb1c",
- "file": "apps/mobile/src/features/grid-detail/ui/report-modal.tsx",
+ "file": "apps/mobile/src/features/video-actions/ui/report-modal.tsx",
"lang": "typescript",
- "start_line": 119,
- "end_line": 134,
+ "start_line": 116,
+ "end_line": 131,
"kind": "Block",
"name": "pressable",
"is_fragment": false
@@ -22634,143 +21707,16 @@
]
},
{
- "id": "f8d569e6f2349d65",
- "note": "consolidate `text` — 11 copies",
- "members": [
- {
- "id": "a4b3a04d9b96959b",
- "source_digest": "fnv1a64:de849840bc39c105",
- "file": "apps/mobile/src/features/event/ui/event-video-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 119,
- "end_line": 126,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "69530e53fb28fc1d",
- "source_digest": "fnv1a64:24586f3dd4332a1a",
- "file": "apps/mobile/src/features/map-home/ui/hourly-bars.tsx",
- "lang": "typescript",
- "start_line": 45,
- "end_line": 50,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "7ee176ee173031a9",
- "source_digest": "fnv1a64:ecc1fa8d56bfa04a",
- "file": "apps/mobile/src/features/upload/ui/analyzing-screen.tsx",
- "lang": "typescript",
- "start_line": 144,
- "end_line": 148,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "c30d9b667d5a51cb",
- "source_digest": "fnv1a64:391309ae2646a5f8",
- "file": "apps/mobile/src/features/dex/ui/region-gallery-view.tsx",
- "lang": "typescript",
- "start_line": 89,
- "end_line": 92,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "bde77017cb57eae5",
- "source_digest": "fnv1a64:62336ff2fbe41afd",
- "file": "apps/mobile/src/features/dex/ui/dex-header-summary.tsx",
- "lang": "typescript",
- "start_line": 57,
- "end_line": 59,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "55382284461afa5e",
- "source_digest": "fnv1a64:d13b1bc892dde6ef",
- "file": "apps/mobile/src/features/event/ui/event-video-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 131,
- "end_line": 133,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "3d091dc575d8253a",
- "source_digest": "fnv1a64:7b1984bb24a8cda7",
- "file": "apps/mobile/src/features/event/ui/event-video-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 138,
- "end_line": 140,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "705abc1e5fb9c742",
- "source_digest": "fnv1a64:5b94e4ac662a663e",
- "file": "apps/mobile/src/features/map-home/ui/mission-detail-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 113,
- "end_line": 115,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "c150402d82975723",
- "source_digest": "fnv1a64:70050e71e6dd12cd",
- "file": "apps/mobile/src/features/search/ui/search-screen.tsx",
- "lang": "typescript",
- "start_line": 162,
- "end_line": 164,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "2412fba9f7fa59dc",
- "source_digest": "fnv1a64:df468217c34fd673",
- "file": "apps/mobile/src/features/upload/ui/highlight-screen.tsx",
- "lang": "typescript",
- "start_line": 100,
- "end_line": 102,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- },
- {
- "id": "efb547b7cf3a6b57",
- "source_digest": "fnv1a64:8e786968c10fa068",
- "file": "packages/ui-native/src/progress-bar.stories.tsx",
- "lang": "typescript",
- "start_line": 24,
- "end_line": 26,
- "kind": "Block",
- "name": "text",
- "is_fragment": false
- }
- ]
- },
- {
- "id": "f8ea0a94c7b9fe34",
+ "id": "e1627a800d7ef9da",
"note": "consolidate `view` — 4 copies",
"members": [
{
- "id": "f20123e90e9c25b0",
+ "id": "b2ad4ea2108a065e",
"source_digest": "fnv1a64:3d4963c6a683fee8",
"file": "apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx",
"lang": "typescript",
- "start_line": 167,
- "end_line": 182,
+ "start_line": 126,
+ "end_line": 141,
"kind": "Block",
"name": "view",
"is_fragment": false
@@ -22927,32 +21873,6 @@
}
]
},
- {
- "id": "fb41460b6f7405ef",
- "note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
- "members": [
- {
- "id": "b2df6dc748359111",
- "source_digest": "fnv1a64:5a476d1d6944489d",
- "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
- "lang": "typescript",
- "start_line": 48,
- "end_line": 70,
- "kind": "Block",
- "is_fragment": false
- },
- {
- "id": "54570c038fb4bb5b",
- "source_digest": "fnv1a64:b765907892380505",
- "file": "apps/web/src/features/region/model/use-explore-regions-query.test.tsx",
- "lang": "typescript",
- "start_line": 45,
- "end_line": 58,
- "kind": "Block",
- "is_fragment": false
- }
- ]
- },
{
"id": "fb7052561f190283",
"note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
@@ -23283,8 +22203,8 @@
]
},
{
- "id": "ff7f63eb851c5007",
- "note": "consolidate `loadWeb` — 32 copies — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "id": "f12311d2f1d336ed",
+ "note": "consolidate `loadWeb` — 33 copies — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
"members": [
{
"id": "6c8ccf366fc12d94",
@@ -23637,6 +22557,17 @@
"kind": "Function",
"name": "loadWeb",
"is_fragment": false
+ },
+ {
+ "id": "3a5a32d751a88ae9",
+ "source_digest": "fnv1a64:8fa3fd47acb9effe",
+ "file": "apps/mobile/src/features/search/model/zone-search.parity.test.ts",
+ "lang": "typescript",
+ "start_line": 34,
+ "end_line": 34,
+ "kind": "Function",
+ "name": "loadWeb",
+ "is_fragment": false
}
]
},
@@ -23693,101 +22624,40 @@
]
},
{
- "id": "8de3831c4b1c40ce",
- "note": "repeated across 3 directories — consolidate one shared type/API contract",
+ "id": "9e121255d17bb5fd",
+ "note": "repeated across 3 directories — consolidate one shared interface/protocol contract",
"members": [
{
- "id": "c299746e4dd847d6",
- "source_digest": "fnv1a64:661a32d8928fde22",
- "file": "apps/web/src/shared/api/generated/types.gen.ts",
+ "id": "44a7ce809fdf8d8b",
+ "source_digest": "fnv1a64:24c45cb43d83f42e",
+ "file": "apps/web/src/pages/map-home/ui/MapCanvas.tsx",
"lang": "typescript",
- "start_line": 2874,
- "end_line": 2899,
+ "start_line": 114,
+ "end_line": 128,
"kind": "Class",
- "name": "MissionRegionAggregateResponseDto",
+ "name": "MapClusterOverlay",
"is_fragment": false
},
{
- "id": "706cb3994df9069f",
- "source_digest": "fnv1a64:58eeaa2c0359e931",
- "file": "apps/web/src/shared/api/generated/types.gen.ts",
+ "id": "a2ee45ca302b0d3e",
+ "source_digest": "fnv1a64:b4032ab9ff4716b9",
+ "file": "apps/mobile/src/features/map-home/model/region-cluster-overlay.ts",
"lang": "typescript",
- "start_line": 2966,
- "end_line": 2991,
+ "start_line": 19,
+ "end_line": 32,
"kind": "Class",
- "name": "HotZoneRegionAggregateResponseDto",
+ "name": "RegionClusterMarker",
"is_fragment": false
},
{
- "id": "7b4615ccf0500995",
- "source_digest": "fnv1a64:38615688460d6acf",
- "file": "apps/web/src/shared/api/generated/types.gen.ts",
+ "id": "a20488e43120eb84",
+ "source_digest": "fnv1a64:dc23b8843797cf11",
+ "file": "apps/web/src/features/map-home/model/region-cluster-overlay.ts",
"lang": "typescript",
- "start_line": 3310,
- "end_line": 3331,
+ "start_line": 18,
+ "end_line": 31,
"kind": "Class",
- "name": "RegionAggregateResponseDto",
- "is_fragment": false
- },
- {
- "id": "54da493323d090d1",
- "source_digest": "fnv1a64:6d8fa13da1f218f4",
- "file": "apps/mobile/src/features/map-home/model/region-cluster-overlay.ts",
- "lang": "typescript",
- "start_line": 41,
- "end_line": 47,
- "kind": "Class",
- "name": "ClusterAggregateItem",
- "is_fragment": false
- },
- {
- "id": "3bc98251a15e8d45",
- "source_digest": "fnv1a64:6d8fa13da1f218f4",
- "file": "apps/web/src/features/map-home/model/region-cluster-overlay.ts",
- "lang": "typescript",
- "start_line": 40,
- "end_line": 46,
- "kind": "Class",
- "name": "ClusterAggregateItem",
- "is_fragment": false
- }
- ]
- },
- {
- "id": "9e121255d17bb5fd",
- "note": "repeated across 3 directories — consolidate one shared interface/protocol contract",
- "members": [
- {
- "id": "44a7ce809fdf8d8b",
- "source_digest": "fnv1a64:24c45cb43d83f42e",
- "file": "apps/web/src/pages/map-home/ui/MapCanvas.tsx",
- "lang": "typescript",
- "start_line": 114,
- "end_line": 128,
- "kind": "Class",
- "name": "MapClusterOverlay",
- "is_fragment": false
- },
- {
- "id": "a2ee45ca302b0d3e",
- "source_digest": "fnv1a64:b4032ab9ff4716b9",
- "file": "apps/mobile/src/features/map-home/model/region-cluster-overlay.ts",
- "lang": "typescript",
- "start_line": 19,
- "end_line": 32,
- "kind": "Class",
- "name": "RegionClusterMarker",
- "is_fragment": false
- },
- {
- "id": "a20488e43120eb84",
- "source_digest": "fnv1a64:dc23b8843797cf11",
- "file": "apps/web/src/features/map-home/model/region-cluster-overlay.ts",
- "lang": "typescript",
- "start_line": 18,
- "end_line": 31,
- "kind": "Class",
- "name": "RegionClusterMarker",
+ "name": "RegionClusterMarker",
"is_fragment": false
}
]
@@ -24192,78 +23062,6 @@
}
]
},
- {
- "id": "f58959cf0f879682",
- "note": "consolidate `pressable` — 6 copies",
- "members": [
- {
- "id": "a40ce1fbe25e6299",
- "source_digest": "fnv1a64:3eeaf78de0024a48",
- "file": "apps/mobile/src/features/permissions/ui/permission-settings-notice.tsx",
- "lang": "typescript",
- "start_line": 39,
- "end_line": 48,
- "kind": "Block",
- "name": "pressable",
- "is_fragment": false
- },
- {
- "id": "6988343b39609c09",
- "source_digest": "fnv1a64:536c6e8e19a54ce3",
- "file": "apps/mobile/src/features/dex/ui/region-gallery-view.tsx",
- "lang": "typescript",
- "start_line": 59,
- "end_line": 66,
- "kind": "Block",
- "name": "pressable",
- "is_fragment": false
- },
- {
- "id": "4360530230bb7793",
- "source_digest": "fnv1a64:f7c1375020ab4dfd",
- "file": "apps/mobile/src/features/event/ui/event-video-player.tsx",
- "lang": "typescript",
- "start_line": 56,
- "end_line": 63,
- "kind": "Block",
- "name": "pressable",
- "is_fragment": false
- },
- {
- "id": "fcffd6cea9dddcc6",
- "source_digest": "fnv1a64:b8ed2e12b39cbff4",
- "file": "apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx",
- "lang": "typescript",
- "start_line": 207,
- "end_line": 214,
- "kind": "Block",
- "name": "pressable",
- "is_fragment": false
- },
- {
- "id": "7b7b17838f554224",
- "source_digest": "fnv1a64:09fc83c99667a207",
- "file": "apps/mobile/src/features/map-home/ui/default-sheet-content.tsx",
- "lang": "typescript",
- "start_line": 81,
- "end_line": 88,
- "kind": "Block",
- "name": "pressable",
- "is_fragment": false
- },
- {
- "id": "a816f895fed3cd56",
- "source_digest": "fnv1a64:f9fc34414868bcfa",
- "file": "apps/mobile/src/features/video-playback/ui/video-player-screen.tsx",
- "lang": "typescript",
- "start_line": 97,
- "end_line": 104,
- "kind": "Block",
- "name": "pressable",
- "is_fragment": false
- }
- ]
- },
{
"id": "b2d466b6bac06ae0",
"note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
@@ -24597,6 +23395,1401 @@
"is_fragment": false
}
]
+ },
+ {
+ "id": "018265a48b8a9823",
+ "note": "consolidate `text` — 10 copies",
+ "members": [
+ {
+ "id": "e012bf0783fef395",
+ "source_digest": "fnv1a64:de849840bc39c105",
+ "file": "apps/mobile/src/features/event/ui/event-video-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 138,
+ "end_line": 145,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "69530e53fb28fc1d",
+ "source_digest": "fnv1a64:24586f3dd4332a1a",
+ "file": "apps/mobile/src/features/map-home/ui/hourly-bars.tsx",
+ "lang": "typescript",
+ "start_line": 45,
+ "end_line": 50,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "7ee176ee173031a9",
+ "source_digest": "fnv1a64:ecc1fa8d56bfa04a",
+ "file": "apps/mobile/src/features/upload/ui/analyzing-screen.tsx",
+ "lang": "typescript",
+ "start_line": 144,
+ "end_line": 148,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "c30d9b667d5a51cb",
+ "source_digest": "fnv1a64:391309ae2646a5f8",
+ "file": "apps/mobile/src/features/dex/ui/region-gallery-view.tsx",
+ "lang": "typescript",
+ "start_line": 89,
+ "end_line": 92,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "bde77017cb57eae5",
+ "source_digest": "fnv1a64:62336ff2fbe41afd",
+ "file": "apps/mobile/src/features/dex/ui/dex-header-summary.tsx",
+ "lang": "typescript",
+ "start_line": 57,
+ "end_line": 59,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "9261f57b2b0f99d2",
+ "source_digest": "fnv1a64:d13b1bc892dde6ef",
+ "file": "apps/mobile/src/features/event/ui/event-video-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 150,
+ "end_line": 152,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "f9230b0a43109ac6",
+ "source_digest": "fnv1a64:7b1984bb24a8cda7",
+ "file": "apps/mobile/src/features/event/ui/event-video-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 157,
+ "end_line": 159,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "705abc1e5fb9c742",
+ "source_digest": "fnv1a64:5b94e4ac662a663e",
+ "file": "apps/mobile/src/features/map-home/ui/mission-detail-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 113,
+ "end_line": 115,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "2412fba9f7fa59dc",
+ "source_digest": "fnv1a64:df468217c34fd673",
+ "file": "apps/mobile/src/features/upload/ui/highlight-screen.tsx",
+ "lang": "typescript",
+ "start_line": 100,
+ "end_line": 102,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "efb547b7cf3a6b57",
+ "source_digest": "fnv1a64:8e786968c10fa068",
+ "file": "packages/ui-native/src/progress-bar.stories.tsx",
+ "lang": "typescript",
+ "start_line": 24,
+ "end_line": 26,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "03fab91440c8c208",
+ "note": "duplicated across 2 directories — extract a method from the repeated block — high-parameter (11 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
+ "members": [
+ {
+ "id": "9b2fe2861a7ac9c5",
+ "source_digest": "fnv1a64:ae6f7393305a729a",
+ "file": "apps/mobile/src/features/report-history/ui/report-history-screen.tsx",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 82,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "dc4e71d9c9936428",
+ "source_digest": "fnv1a64:5f892fd1bbe0cac3",
+ "file": "apps/mobile/src/features/user-block/ui/blocked-users-screen.tsx",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 73,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "0406e8d082428e4c",
+ "note": "repeated across 4 directories — extract a shared abstraction",
+ "members": [
+ {
+ "id": "2fc03103b8c2381f",
+ "source_digest": "fnv1a64:e448a445e10f69a1",
+ "file": "apps/web/src/pages/map-home/ui/CourseDetailPanel.tsx",
+ "lang": "typescript",
+ "start_line": 87,
+ "end_line": 94,
+ "kind": "Block",
+ "name": "p",
+ "is_fragment": false
+ },
+ {
+ "id": "e10b4c9a686c4b72",
+ "source_digest": "fnv1a64:716cb7839c75e2bc",
+ "file": "apps/mobile/src/features/video-playback/ui/video-player-screen.tsx",
+ "lang": "typescript",
+ "start_line": 143,
+ "end_line": 146,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "a82ad40264922171",
+ "source_digest": "fnv1a64:92758f1ee063a9c5",
+ "file": "apps/mobile/src/features/map-home/ui/course-detail-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 86,
+ "end_line": 88,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "fed9ce0b13b70a5c",
+ "source_digest": "fnv1a64:d424320e2c469200",
+ "file": "apps/mobile/src/features/map-home/ui/hot-region-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 77,
+ "end_line": 79,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "cef3f9c30ce393a6",
+ "source_digest": "fnv1a64:c50650cce47d2b3f",
+ "file": "apps/web/src/pages/org/ui/AreaUsageCard.tsx",
+ "lang": "typescript",
+ "start_line": 41,
+ "end_line": 43,
+ "kind": "Block",
+ "name": "p",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "1a535797b6f19335",
+ "note": "repeated across 8 directories — extract a shared abstraction",
+ "members": [
+ {
+ "id": "d51454b086148c53",
+ "source_digest": "fnv1a64:1940585393a5c689",
+ "file": "apps/mobile/src/features/dex/model/dex-format.ts",
+ "lang": "typescript",
+ "start_line": 15,
+ "end_line": 16,
+ "kind": "Function",
+ "name": "formatVideoCount",
+ "is_fragment": false
+ },
+ {
+ "id": "d8ada5989f46e899",
+ "source_digest": "fnv1a64:35ed226eb9d04446",
+ "file": "apps/mobile/src/features/event/model/event-location.ts",
+ "lang": "typescript",
+ "start_line": 74,
+ "end_line": 75,
+ "kind": "Function",
+ "name": "eventLocationGridNotice",
+ "is_fragment": false
+ },
+ {
+ "id": "3f2520dfd1d5ca29",
+ "source_digest": "fnv1a64:762a5e59b2f9d05b",
+ "file": "apps/mobile/src/features/user-block/model/user-block.ts",
+ "lang": "typescript",
+ "start_line": 21,
+ "end_line": 22,
+ "kind": "Function",
+ "name": "blockConfirmTitle",
+ "is_fragment": false
+ },
+ {
+ "id": "847aeab9b8c664af",
+ "source_digest": "fnv1a64:fde6ed42306a844d",
+ "file": "apps/web/src/features/admin-review/model/submission-view.ts",
+ "lang": "typescript",
+ "start_line": 47,
+ "end_line": 48,
+ "kind": "Function",
+ "name": "formatLocationCountLabel",
+ "is_fragment": false
+ },
+ {
+ "id": "1487c402641b474b",
+ "source_digest": "fnv1a64:35ed226eb9d04446",
+ "file": "apps/web/src/features/event/model/event-location.ts",
+ "lang": "typescript",
+ "start_line": 61,
+ "end_line": 62,
+ "kind": "Function",
+ "name": "eventLocationGridNotice",
+ "is_fragment": false
+ },
+ {
+ "id": "4df1cfa2f2790cc5",
+ "source_digest": "fnv1a64:779c8d85a4ab0106",
+ "file": "apps/web/src/features/region/model/region-reload.ts",
+ "lang": "typescript",
+ "start_line": 65,
+ "end_line": 66,
+ "kind": "Function",
+ "name": "reloadLabel",
+ "is_fragment": false
+ },
+ {
+ "id": "df3d0b6619d45dd9",
+ "source_digest": "fnv1a64:6865be29a8534553",
+ "file": "apps/mobile/src/features/dex/model/dex-format.ts",
+ "lang": "typescript",
+ "start_line": 25,
+ "end_line": 25,
+ "kind": "Function",
+ "name": "formatCountTile",
+ "is_fragment": false
+ },
+ {
+ "id": "b0889f3cb8995c24",
+ "source_digest": "fnv1a64:2810881d48cd6c79",
+ "file": "apps/mobile/src/features/dex/model/dex-format.ts",
+ "lang": "typescript",
+ "start_line": 28,
+ "end_line": 28,
+ "kind": "Function",
+ "name": "formatStreakTile",
+ "is_fragment": false
+ },
+ {
+ "id": "b5a134b66a072490",
+ "source_digest": "fnv1a64:ff5025a60ca92908",
+ "file": "apps/mobile/src/features/profile/model/activity-summary.ts",
+ "lang": "typescript",
+ "start_line": 33,
+ "end_line": 33,
+ "kind": "Function",
+ "name": "formatStreakDays",
+ "is_fragment": false
+ },
+ {
+ "id": "12a2c4bdf6ee7592",
+ "source_digest": "fnv1a64:f8a5d2d24dbd46e6",
+ "file": "apps/web/src/features/event/model/event-video-view.ts",
+ "lang": "typescript",
+ "start_line": 104,
+ "end_line": 104,
+ "kind": "Function",
+ "name": "playbackRateLabel",
+ "is_fragment": false
+ },
+ {
+ "id": "348258659c80fd34",
+ "source_digest": "fnv1a64:ff5025a60ca92908",
+ "file": "apps/web/src/features/profile/model/activity-summary.ts",
+ "lang": "typescript",
+ "start_line": 36,
+ "end_line": 36,
+ "kind": "Function",
+ "name": "formatStreakDays",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "3e75b34b427c16d3",
+ "note": "repeated across 3 directories — consolidate one shared type/API contract",
+ "members": [
+ {
+ "id": "5931da22b39c4756",
+ "source_digest": "fnv1a64:661a32d8928fde22",
+ "file": "apps/web/src/shared/api/generated/types.gen.ts",
+ "lang": "typescript",
+ "start_line": 2942,
+ "end_line": 2967,
+ "kind": "Class",
+ "name": "MissionRegionAggregateResponseDto",
+ "is_fragment": false
+ },
+ {
+ "id": "ac2c3d637310d3bf",
+ "source_digest": "fnv1a64:58eeaa2c0359e931",
+ "file": "apps/web/src/shared/api/generated/types.gen.ts",
+ "lang": "typescript",
+ "start_line": 3034,
+ "end_line": 3059,
+ "kind": "Class",
+ "name": "HotZoneRegionAggregateResponseDto",
+ "is_fragment": false
+ },
+ {
+ "id": "e6ee972d0b8a6e90",
+ "source_digest": "fnv1a64:38615688460d6acf",
+ "file": "apps/web/src/shared/api/generated/types.gen.ts",
+ "lang": "typescript",
+ "start_line": 3378,
+ "end_line": 3399,
+ "kind": "Class",
+ "name": "RegionAggregateResponseDto",
+ "is_fragment": false
+ },
+ {
+ "id": "54da493323d090d1",
+ "source_digest": "fnv1a64:6d8fa13da1f218f4",
+ "file": "apps/mobile/src/features/map-home/model/region-cluster-overlay.ts",
+ "lang": "typescript",
+ "start_line": 41,
+ "end_line": 47,
+ "kind": "Class",
+ "name": "ClusterAggregateItem",
+ "is_fragment": false
+ },
+ {
+ "id": "3bc98251a15e8d45",
+ "source_digest": "fnv1a64:6d8fa13da1f218f4",
+ "file": "apps/web/src/features/map-home/model/region-cluster-overlay.ts",
+ "lang": "typescript",
+ "start_line": 40,
+ "end_line": 46,
+ "kind": "Class",
+ "name": "ClusterAggregateItem",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "3f19ec1b8ac82fbb",
+ "note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "9b6c8087938ca580",
+ "source_digest": "fnv1a64:e43b0ce5d834cf71",
+ "file": "apps/mobile/src/test/event-video-fixture.ts",
+ "lang": "typescript",
+ "start_line": 41,
+ "end_line": 47,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "b54ed9617649f874",
+ "source_digest": "fnv1a64:e43b0ce5d834cf71",
+ "file": "apps/web/src/test/event-video-fixture.ts",
+ "lang": "typescript",
+ "start_line": 40,
+ "end_line": 46,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "3f5f02776cb3b004",
+ "note": "repeated across 3 directories — extract a shared abstraction — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "ef99837f08b1fc6b",
+ "source_digest": "fnv1a64:b0ec70cae3c3a183",
+ "file": "apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 113,
+ "end_line": 126,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "5de8757dce6cbfdf",
+ "source_digest": "fnv1a64:5d27c890cccaeab2",
+ "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 157,
+ "end_line": 165,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "559b3ceb1ba9f746",
+ "source_digest": "fnv1a64:353bb3b5c30ff40b",
+ "file": "apps/web/src/features/region/model/use-explore-regions-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 141,
+ "end_line": 149,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "2e81b33e032e9640",
+ "source_digest": "fnv1a64:7bea79907dba0f84",
+ "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 50,
+ "end_line": 55,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "7cca1263a6030e08",
+ "source_digest": "fnv1a64:68d6e54c2fcfab1f",
+ "file": "apps/web/src/pages/map-home/ui/region-list-view.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 80,
+ "end_line": 84,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "4d8e9a9f68297342",
+ "note": "duplicated across 2 directories — extract a helper",
+ "members": [
+ {
+ "id": "d701765da46ccda9",
+ "source_digest": "fnv1a64:f3dc21d104cc048d",
+ "file": "apps/mobile/src/features/profile/model/profile-format.ts",
+ "lang": "typescript",
+ "start_line": 18,
+ "end_line": 18,
+ "kind": "Function",
+ "name": "formatJoinedDate",
+ "is_fragment": false
+ },
+ {
+ "id": "4aabde4b140db33e",
+ "source_digest": "fnv1a64:ba48846dfb6087f6",
+ "file": "apps/mobile/src/features/report-history/model/report-history.ts",
+ "lang": "typescript",
+ "start_line": 101,
+ "end_line": 101,
+ "kind": "Function",
+ "name": "formatReportedAt",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "4fffb147132640fa",
+ "note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "67556c839d18cda3",
+ "source_digest": "fnv1a64:e6d823995a92dace",
+ "file": "apps/mobile/src/features/event/model/location-videos-query.parity.test.ts",
+ "lang": "typescript",
+ "start_line": 37,
+ "end_line": 45,
+ "kind": "Block",
+ "is_fragment": true,
+ "fragment_kind": "direct-return",
+ "reason_code": "exact-direct-return"
+ },
+ {
+ "id": "12845bdffc8b5078",
+ "source_digest": "fnv1a64:d8b80b6a7ee28b5c",
+ "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 28,
+ "end_line": 36,
+ "kind": "Block",
+ "is_fragment": true,
+ "fragment_kind": "direct-return",
+ "reason_code": "exact-direct-return"
+ }
+ ]
+ },
+ {
+ "id": "54bda1f5abc96a53",
+ "note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "78bde03208eaae7d",
+ "source_digest": "fnv1a64:5a476d1d6944489d",
+ "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 49,
+ "end_line": 71,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "54570c038fb4bb5b",
+ "source_digest": "fnv1a64:b765907892380505",
+ "file": "apps/web/src/features/region/model/use-explore-regions-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 45,
+ "end_line": 58,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "56bc216763937aba",
+ "note": "consolidate `view` — 2 copies",
+ "members": [
+ {
+ "id": "7120eeaa0a73334e",
+ "source_digest": "fnv1a64:e30a5e3000d0c053",
+ "file": "apps/mobile/src/features/map-home/ui/course-spot-row.tsx",
+ "lang": "typescript",
+ "start_line": 67,
+ "end_line": 80,
+ "kind": "Block",
+ "name": "view",
+ "is_fragment": false
+ },
+ {
+ "id": "39327152860ff6e9",
+ "source_digest": "fnv1a64:13f3bec96e55ae61",
+ "file": "apps/mobile/src/features/event/ui/event-video-comment-row.tsx",
+ "lang": "typescript",
+ "start_line": 38,
+ "end_line": 48,
+ "kind": "Block",
+ "name": "view",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "58f94f3ae9519382",
+ "note": "consolidate `text` — 14 copies",
+ "members": [
+ {
+ "id": "39d037a43fe5a862",
+ "source_digest": "fnv1a64:0af25d6ebf680b2b",
+ "file": "apps/mobile/src/app/dev/api-smoke.tsx",
+ "lang": "typescript",
+ "start_line": 129,
+ "end_line": 131,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "f9bd650017484b08",
+ "source_digest": "fnv1a64:6638c59c4b57ea45",
+ "file": "apps/mobile/src/features/event/ui/event-overview-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 59,
+ "end_line": 61,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "96aa8ecfbe7beb3c",
+ "source_digest": "fnv1a64:4e125af0a77491ef",
+ "file": "apps/mobile/src/features/event/ui/event-overview-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 76,
+ "end_line": 78,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "68349831778b5f32",
+ "source_digest": "fnv1a64:dfa578ff0cbd7a89",
+ "file": "apps/mobile/src/features/event/ui/event-video-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 110,
+ "end_line": 112,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "63d14d6f529e8db1",
+ "source_digest": "fnv1a64:3230166a7f6d487c",
+ "file": "apps/mobile/src/features/map-home/ui/course-detail-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 106,
+ "end_line": 108,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "b9bf9db80581ab4d",
+ "source_digest": "fnv1a64:32cfb99b28cd1753",
+ "file": "apps/mobile/src/features/map-home/ui/course-detail-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 109,
+ "end_line": 111,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "05314d051a82f78c",
+ "source_digest": "fnv1a64:3948dfdf7bafa22f",
+ "file": "apps/mobile/src/features/map-home/ui/grid-detail-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 107,
+ "end_line": 109,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "67d9652f93c26bd6",
+ "source_digest": "fnv1a64:e4610cb0f72cee9b",
+ "file": "apps/mobile/src/features/map-home/ui/hourly-bars.tsx",
+ "lang": "typescript",
+ "start_line": 24,
+ "end_line": 26,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "49199f5fa245b562",
+ "source_digest": "fnv1a64:97ea97f1176c5669",
+ "file": "apps/mobile/src/features/map-home/ui/mission-detail-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 87,
+ "end_line": 89,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "c728ce3346aae007",
+ "source_digest": "fnv1a64:65efa12a318acc5a",
+ "file": "apps/mobile/src/features/map-home/ui/region-list-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 72,
+ "end_line": 74,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "33e230d39aea8f5b",
+ "source_digest": "fnv1a64:57af94b47467653a",
+ "file": "apps/mobile/src/features/search/ui/search-screen.tsx",
+ "lang": "typescript",
+ "start_line": 200,
+ "end_line": 202,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "9233e5ed1d7dc62a",
+ "source_digest": "fnv1a64:bd449dc420f45d9a",
+ "file": "packages/ui-native/src/segmented-progress.stories.tsx",
+ "lang": "typescript",
+ "start_line": 30,
+ "end_line": 32,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "c2d44b38e05554c4",
+ "source_digest": "fnv1a64:a00a2c38f82daee8",
+ "file": "apps/mobile/src/features/event/ui/event-badge-header.tsx",
+ "lang": "typescript",
+ "start_line": 16,
+ "end_line": 16,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ },
+ {
+ "id": "b6148ec8de7c389b",
+ "source_digest": "fnv1a64:a00a2c38f82daee8",
+ "file": "apps/mobile/src/features/map-home/ui/theme-badge-header.tsx",
+ "lang": "typescript",
+ "start_line": 25,
+ "end_line": 25,
+ "kind": "Block",
+ "name": "text",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "5dc62f0e75d2dc3e",
+ "note": "local duplication — extract a helper — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "68a3028b7c47ea44",
+ "source_digest": "fnv1a64:cf4cdd47e4b7fcd1",
+ "file": "apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 31,
+ "end_line": 43,
+ "kind": "Function",
+ "name": "video",
+ "is_fragment": false
+ },
+ {
+ "id": "f342d28e5fed898f",
+ "source_digest": "fnv1a64:0cca553a9ba85d87",
+ "file": "apps/web/src/pages/map-home/ui/event-archive-body.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 218,
+ "end_line": 226,
+ "kind": "Function",
+ "name": "archiveVideo",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "5dea9b76bd48529f",
+ "note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "ebedd5eb4e902c97",
+ "source_digest": "fnv1a64:60108eea9e1f56d2",
+ "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 122,
+ "end_line": 130,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "ec1acd326235b591",
+ "source_digest": "fnv1a64:96698107f3bb8805",
+ "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 168,
+ "end_line": 174,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "6b66b53df6a5224b",
+ "note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "6579ee889699da8c",
+ "source_digest": "fnv1a64:27a778a55155252e",
+ "file": "apps/web/src/features/region/model/use-explore-regions-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 111,
+ "end_line": 121,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "c16a6f35131452f9",
+ "source_digest": "fnv1a64:f7c26ea97f99e084",
+ "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 124,
+ "end_line": 130,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "25318000d9c304ee",
+ "source_digest": "fnv1a64:cba31d48d0813332",
+ "file": "apps/web/src/features/region/model/use-explore-regions-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 152,
+ "end_line": 156,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "79ceaa48c76d0e7e",
+ "note": "duplicated across 2 directories — extract a method from the repeated block — high-parameter (15 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
+ "members": [
+ {
+ "id": "244381a727769c43",
+ "source_digest": "fnv1a64:47b3660396a4a547",
+ "file": "apps/mobile/src/features/profile/ui/profile-screen.tsx",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 273,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "1e5b36fdb889f368",
+ "source_digest": "fnv1a64:a716034d2e5ed2d0",
+ "file": "apps/mobile/src/features/upload/ui/upload-screen.tsx",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 165,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "85211c5c173397bc",
+ "note": "duplicated across 2 directories — consolidate one shared type/API contract",
+ "members": [
+ {
+ "id": "47c240cbc533f614",
+ "source_digest": "fnv1a64:5ec8f7d1a6c032d6",
+ "file": "apps/web/src/shared/api/generated/types.gen.ts",
+ "lang": "typescript",
+ "start_line": 4020,
+ "end_line": 4053,
+ "kind": "Class",
+ "name": "RegionVideoResponseDto",
+ "is_fragment": false
+ },
+ {
+ "id": "c22c4838482876fd",
+ "source_digest": "fnv1a64:7af7a3bbd9dd8a20",
+ "file": "apps/web/src/features/video-actions/model/video-menu.ts",
+ "lang": "typescript",
+ "start_line": 25,
+ "end_line": 35,
+ "kind": "Class",
+ "name": "VideoActionTarget",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "94d7bf43a423a2a4",
+ "note": "consolidate `pressable` — 6 copies",
+ "members": [
+ {
+ "id": "a40ce1fbe25e6299",
+ "source_digest": "fnv1a64:3eeaf78de0024a48",
+ "file": "apps/mobile/src/features/permissions/ui/permission-settings-notice.tsx",
+ "lang": "typescript",
+ "start_line": 39,
+ "end_line": 48,
+ "kind": "Block",
+ "name": "pressable",
+ "is_fragment": false
+ },
+ {
+ "id": "6988343b39609c09",
+ "source_digest": "fnv1a64:536c6e8e19a54ce3",
+ "file": "apps/mobile/src/features/dex/ui/region-gallery-view.tsx",
+ "lang": "typescript",
+ "start_line": 59,
+ "end_line": 66,
+ "kind": "Block",
+ "name": "pressable",
+ "is_fragment": false
+ },
+ {
+ "id": "4360530230bb7793",
+ "source_digest": "fnv1a64:f7c1375020ab4dfd",
+ "file": "apps/mobile/src/features/event/ui/event-video-player.tsx",
+ "lang": "typescript",
+ "start_line": 56,
+ "end_line": 63,
+ "kind": "Block",
+ "name": "pressable",
+ "is_fragment": false
+ },
+ {
+ "id": "860bae0ea704d9f0",
+ "source_digest": "fnv1a64:b8ed2e12b39cbff4",
+ "file": "apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx",
+ "lang": "typescript",
+ "start_line": 166,
+ "end_line": 173,
+ "kind": "Block",
+ "name": "pressable",
+ "is_fragment": false
+ },
+ {
+ "id": "7b7b17838f554224",
+ "source_digest": "fnv1a64:09fc83c99667a207",
+ "file": "apps/mobile/src/features/map-home/ui/default-sheet-content.tsx",
+ "lang": "typescript",
+ "start_line": 81,
+ "end_line": 88,
+ "kind": "Block",
+ "name": "pressable",
+ "is_fragment": false
+ },
+ {
+ "id": "96fb02b31d42bc76",
+ "source_digest": "fnv1a64:f9fc34414868bcfa",
+ "file": "apps/mobile/src/features/video-playback/ui/video-player-screen.tsx",
+ "lang": "typescript",
+ "start_line": 117,
+ "end_line": 124,
+ "kind": "Block",
+ "name": "pressable",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "a189cfbc3ea5b0b8",
+ "note": "local duplication — extract a method from the repeated block — high-parameter (20 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
+ "members": [
+ {
+ "id": "9c52a4a96e6742c8",
+ "source_digest": "fnv1a64:fc400d534a1e4974",
+ "file": "apps/web/src/pages/map-home/ui/event-archive-body.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 401,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "f4b0ae8a66eb2251",
+ "source_digest": "fnv1a64:2104772191be3089",
+ "file": "apps/web/src/pages/map-home/ui/event-capsule.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 211,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "a2b80482e755ec09",
+ "note": "duplicated across 2 directories — consolidate one shared type/API contract",
+ "members": [
+ {
+ "id": "1cd6c0cea5243e9b",
+ "source_digest": "fnv1a64:3bbd4dc0b9e62686",
+ "file": "apps/web/src/shared/api/generated/types.gen.ts",
+ "lang": "typescript",
+ "start_line": 968,
+ "end_line": 993,
+ "kind": "Class",
+ "name": "OrgAccountRequestCreateRequestDto",
+ "is_fragment": false
+ },
+ {
+ "id": "18b27e00a11b0c7b",
+ "source_digest": "fnv1a64:6df97324c01445ac",
+ "file": "apps/web/src/pages/org/account-request-form.ts",
+ "lang": "typescript",
+ "start_line": 34,
+ "end_line": 42,
+ "kind": "Class",
+ "name": "AccountRequestDraft",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "a2e58e9857db9300",
+ "note": "repeated across 7 directories — extract a shared abstraction — high-parameter (18 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
+ "members": [
+ {
+ "id": "83f065fc67a38305",
+ "source_digest": "fnv1a64:4bda003a9928b261",
+ "file": "apps/mobile/src/features/ai-route/api/route-recommend-mutation.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 362,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "00e80863d01d6dbe",
+ "source_digest": "fnv1a64:b562239791050fbd",
+ "file": "apps/mobile/src/features/video-actions/api/video-mutations.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 356,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "df2cb523e5f121a1",
+ "source_digest": "fnv1a64:7ab579481b63aca2",
+ "file": "apps/mobile/src/features/user-block/api/user-block-mutations.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 261,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "c9b9884efc6a29b6",
+ "source_digest": "fnv1a64:22968ef0cd99615f",
+ "file": "apps/mobile/src/features/event/api/event-video-mutations.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 243,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "550baae39321331b",
+ "source_digest": "fnv1a64:1491ab04aa990de3",
+ "file": "apps/mobile/src/features/profile/api/use-notification-toggle.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 210,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "37c7795192016805",
+ "source_digest": "fnv1a64:a45fab8a87ce41fe",
+ "file": "apps/mobile/src/features/profile/api/marketing-consent-mutation.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 187,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "0a41e871b8cf6eff",
+ "source_digest": "fnv1a64:29dac2a22dff6e74",
+ "file": "apps/mobile/src/features/profile/api/use-profile-image-upload.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 175,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "977c30de5383937e",
+ "source_digest": "fnv1a64:f203380283efb929",
+ "file": "apps/mobile/src/features/auth/api/kakao-login-mutation.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 173,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "aa4c9e9a9c09a61d",
+ "source_digest": "fnv1a64:a566aabfdf9fc714",
+ "file": "apps/mobile/src/features/profile/api/use-remove-profile-image.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 120,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "5051f2c3798f50eb",
+ "source_digest": "fnv1a64:82442ccd5228876f",
+ "file": "apps/mobile/src/features/profile/api/use-update-nickname.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 108,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "6bffc25be1241e61",
+ "source_digest": "fnv1a64:a47fbf3e929ad427",
+ "file": "apps/mobile/src/features/dex/api/use-badge-mutations.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 104,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "f1ce9d548edea18d",
+ "source_digest": "fnv1a64:af8d3fa405718272",
+ "file": "apps/mobile/src/features/profile/api/delete-account-mutation.test.ts",
+ "lang": "typescript",
+ "start_line": 1,
+ "end_line": 95,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "ac6ad92314563c34",
+ "note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "9cbcabab30e517f6",
+ "source_digest": "fnv1a64:a59ea789f633665d",
+ "file": "apps/web/src/pages/map-home/ui/event-archive-body.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 370,
+ "end_line": 375,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "8ae8acc1cf1bc33b",
+ "source_digest": "fnv1a64:51c83aca72a3ada2",
+ "file": "apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 215,
+ "end_line": 220,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "b34901fbf9ba60c8",
+ "note": "repeated across 5 directories — extract a shared abstraction — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "e5792c44e6811144",
+ "source_digest": "fnv1a64:181266e0b677c31d",
+ "file": "apps/mobile/src/features/profile/model/notification-toggle.test.ts",
+ "lang": "typescript",
+ "start_line": 18,
+ "end_line": 24,
+ "kind": "Function",
+ "name": "envelope",
+ "is_fragment": false
+ },
+ {
+ "id": "0860a8ed256d440e",
+ "source_digest": "fnv1a64:da6774137205c02f",
+ "file": "apps/web/src/features/admin-events/api/use-unpublish-event.test.tsx",
+ "lang": "typescript",
+ "start_line": 16,
+ "end_line": 20,
+ "kind": "Block",
+ "is_fragment": true,
+ "fragment_kind": "direct-return",
+ "reason_code": "exact-direct-return"
+ },
+ {
+ "id": "8daa8686daa0edff",
+ "source_digest": "fnv1a64:9df08daf5253a1d7",
+ "file": "apps/web/src/pages/org/submission-edit.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 92,
+ "end_line": 96,
+ "kind": "Function",
+ "name": "detailEnvelope",
+ "is_fragment": false
+ },
+ {
+ "id": "1d1cf65955d5983a",
+ "source_digest": "fnv1a64:e1677d2fde494d1b",
+ "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 57,
+ "end_line": 57,
+ "kind": "Function",
+ "name": "envelope",
+ "is_fragment": false
+ },
+ {
+ "id": "c5fadab270263b49",
+ "source_digest": "fnv1a64:e1677d2fde494d1b",
+ "file": "apps/web/src/features/region/model/use-explore-regions-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 53,
+ "end_line": 53,
+ "kind": "Function",
+ "name": "envelope",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "b3d50719dc28454e",
+ "note": "consolidate `CellSeed` — 2 copies",
+ "members": [
+ {
+ "id": "3338032d780a8ec6",
+ "source_digest": "fnv1a64:3337121a3a703e12",
+ "file": "apps/web/src/entities/cell/model/mock-cells.ts",
+ "lang": "typescript",
+ "start_line": 85,
+ "end_line": 99,
+ "kind": "Class",
+ "name": "CellSeed",
+ "is_fragment": false
+ },
+ {
+ "id": "a09af7b7ef17119d",
+ "source_digest": "fnv1a64:6b5a41f0d3cf9452",
+ "file": "apps/mobile/src/features/grid-detail/model/mock-cell-details.ts",
+ "lang": "typescript",
+ "start_line": 64,
+ "end_line": 75,
+ "kind": "Class",
+ "name": "CellSeed",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "c1cbcd7cd21ceeb2",
+ "note": "consolidate `pressable` — 4 copies",
+ "members": [
+ {
+ "id": "d7b3693cb0e00caf",
+ "source_digest": "fnv1a64:7d9b62c48e5f6496",
+ "file": "apps/mobile/src/features/video-playback/ui/video-player-screen.tsx",
+ "lang": "typescript",
+ "start_line": 152,
+ "end_line": 160,
+ "kind": "Block",
+ "name": "pressable",
+ "is_fragment": false
+ },
+ {
+ "id": "ba900f1ec9946c5f",
+ "source_digest": "fnv1a64:25a509487e6cc39d",
+ "file": "packages/ui-native/src/action-sheet.tsx",
+ "lang": "typescript",
+ "start_line": 56,
+ "end_line": 64,
+ "kind": "Block",
+ "name": "pressable",
+ "is_fragment": false
+ },
+ {
+ "id": "0e9d74583278a3a5",
+ "source_digest": "fnv1a64:63aef2d25bd339bb",
+ "file": "packages/ui-native/src/modal-card.tsx",
+ "lang": "typescript",
+ "start_line": 86,
+ "end_line": 94,
+ "kind": "Block",
+ "name": "pressable",
+ "is_fragment": false
+ },
+ {
+ "id": "19d1904fcad97b00",
+ "source_digest": "fnv1a64:3c1322d76b72004f",
+ "file": "packages/ui-native/src/bottom-sheet.tsx",
+ "lang": "typescript",
+ "start_line": 55,
+ "end_line": 61,
+ "kind": "Block",
+ "name": "pressable",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "df4a6f5aac5b64a9",
+ "note": "local duplication — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "5112889c48386a7f",
+ "source_digest": "fnv1a64:b8773884418e0df0",
+ "file": "apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 112,
+ "end_line": 135,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "4678675de6924a73",
+ "source_digest": "fnv1a64:14a4e35b40cda9e8",
+ "file": "apps/web/src/pages/map-home/ui/region-list-view.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 94,
+ "end_line": 103,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "e1208a5606b459a1",
+ "note": "duplicated across 2 directories — extract a method from the repeated block — test scaffolding: consolidate only a genuinely shared fixture/helper, not per-scenario setup",
+ "members": [
+ {
+ "id": "23dd1aa6e3bf903e",
+ "source_digest": "fnv1a64:a1eaa5e8f53daaa5",
+ "file": "apps/web/src/pages/map-home/ui/event-room-videos.smoke.test.tsx",
+ "lang": "typescript",
+ "start_line": 151,
+ "end_line": 171,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "8c2488476193e50a",
+ "source_digest": "fnv1a64:cf691c64394bd0e6",
+ "file": "apps/web/src/features/event/model/use-location-videos-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 134,
+ "end_line": 153,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "69a42a60ecd8d4a5",
+ "source_digest": "fnv1a64:570a678b8441030a",
+ "file": "apps/web/src/features/event/model/use-event-video-detail-query.test.tsx",
+ "lang": "typescript",
+ "start_line": 44,
+ "end_line": 62,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
+ },
+ {
+ "id": "ea6426a6dd6af658",
+ "note": "duplicated across 2 directories — extract a method from the repeated block — high-parameter (8 varying spots): divergence readability; a smaller helper for the invariant core may fit better",
+ "members": [
+ {
+ "id": "d29ccf9d71d7a169",
+ "source_digest": "fnv1a64:e5f7e0444e4ce324",
+ "file": "apps/mobile/src/features/event/api/use-event-comments-pages.ts",
+ "lang": "typescript",
+ "start_line": 46,
+ "end_line": 110,
+ "kind": "Block",
+ "is_fragment": false
+ },
+ {
+ "id": "5a3b97a17f77d174",
+ "source_digest": "fnv1a64:05afb0183bbe72de",
+ "file": "apps/web/src/features/event/model/use-event-comments-pages.ts",
+ "lang": "typescript",
+ "start_line": 22,
+ "end_line": 84,
+ "kind": "Block",
+ "is_fragment": false
+ }
+ ]
}
]
}