diff --git a/src/features/retrospectives/hooks/index.ts b/src/features/retrospectives/hooks/index.ts new file mode 100644 index 0000000..ceb6492 --- /dev/null +++ b/src/features/retrospectives/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './retrospectiveQueryKeys' +export * from './useCreateSttJob' +export * from './usePublishSummary' +export * from './useSummary' +export * from './useUpdateSummary' diff --git a/src/features/retrospectives/hooks/retrospectiveQueryKeys.ts b/src/features/retrospectives/hooks/retrospectiveQueryKeys.ts new file mode 100644 index 0000000..de28939 --- /dev/null +++ b/src/features/retrospectives/hooks/retrospectiveQueryKeys.ts @@ -0,0 +1,6 @@ +export const retrospectiveQueryKeys = { + all: ['retrospectives'] as const, + + summaries: () => [...retrospectiveQueryKeys.all, 'summary'] as const, + summary: (meetingId: number) => [...retrospectiveQueryKeys.summaries(), meetingId] as const, +} diff --git a/src/features/retrospectives/hooks/useCreateSttJob.ts b/src/features/retrospectives/hooks/useCreateSttJob.ts new file mode 100644 index 0000000..f87ada9 --- /dev/null +++ b/src/features/retrospectives/hooks/useCreateSttJob.ts @@ -0,0 +1,50 @@ +import { useMutation } from '@tanstack/react-query' +import { useCallback, useEffect, useRef } from 'react' + +import type { ApiError } from '@/api' + +import { createSttJob } from '../retrospectives.api' +import type { CreateSttJobParams, SttJobResponse } from '../retrospectives.types' + +/** + * STT Job 생성 (AI 요약 트리거) mutation 훅 + * + * - 동기 API이므로 mutation pending 동안 로딩 오버레이 표시 + * - cancel() 호출 시 진행 중인 HTTP 요청 중단 + * - 컴포넌트 언마운트 시 자동 취소 + */ +export const useCreateSttJob = () => { + const abortControllerRef = useRef(null) + + useEffect(() => { + return () => { + abortControllerRef.current?.abort() + abortControllerRef.current = null + } + }, []) + + const mutation = useMutation({ + mutationFn: (params: CreateSttJobParams) => { + abortControllerRef.current?.abort() + + const controller = new AbortController() + abortControllerRef.current = controller + + return createSttJob(params, controller.signal).finally(() => { + if (abortControllerRef.current === controller) { + abortControllerRef.current = null + } + }) + }, + }) + + const { reset } = mutation + + const cancel = useCallback(() => { + abortControllerRef.current?.abort() + abortControllerRef.current = null + reset() + }, [reset]) + + return { ...mutation, cancel } +} diff --git a/src/features/retrospectives/hooks/usePublishSummary.ts b/src/features/retrospectives/hooks/usePublishSummary.ts new file mode 100644 index 0000000..1636fd6 --- /dev/null +++ b/src/features/retrospectives/hooks/usePublishSummary.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { publishSummary } from '../retrospectives.api' +import type { PublishSummaryParams, RetrospectiveSummaryResponse } from '../retrospectives.types' +import { retrospectiveQueryKeys } from './retrospectiveQueryKeys' + +export const usePublishSummary = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (params: PublishSummaryParams) => publishSummary(params), + onSuccess: (data, variables) => { + queryClient.setQueryData(retrospectiveQueryKeys.summary(variables.meetingId), data) + }, + }) +} diff --git a/src/features/retrospectives/hooks/useSummary.ts b/src/features/retrospectives/hooks/useSummary.ts new file mode 100644 index 0000000..1a9abfe --- /dev/null +++ b/src/features/retrospectives/hooks/useSummary.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { getSummary } from '../retrospectives.api' +import type { RetrospectiveSummaryResponse } from '../retrospectives.types' +import { retrospectiveQueryKeys } from './retrospectiveQueryKeys' + +export const useSummary = (meetingId: number) => { + const isValid = !Number.isNaN(meetingId) && meetingId > 0 + + return useQuery({ + queryKey: retrospectiveQueryKeys.summary(meetingId), + queryFn: () => getSummary(meetingId), + enabled: isValid, + }) +} diff --git a/src/features/retrospectives/hooks/useUpdateSummary.ts b/src/features/retrospectives/hooks/useUpdateSummary.ts new file mode 100644 index 0000000..c2ecdac --- /dev/null +++ b/src/features/retrospectives/hooks/useUpdateSummary.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { updateSummary } from '../retrospectives.api' +import type { RetrospectiveSummaryResponse, UpdateSummaryParams } from '../retrospectives.types' +import { retrospectiveQueryKeys } from './retrospectiveQueryKeys' + +export const useUpdateSummary = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (params: UpdateSummaryParams) => updateSummary(params), + onSuccess: (data, variables) => { + queryClient.setQueryData(retrospectiveQueryKeys.summary(variables.meetingId), data) + }, + }) +} diff --git a/src/features/retrospectives/index.ts b/src/features/retrospectives/index.ts index 4ffb8fb..7a3aafd 100644 --- a/src/features/retrospectives/index.ts +++ b/src/features/retrospectives/index.ts @@ -1,2 +1,23 @@ // Components export * from './components' + +// Hooks +export * from './hooks' + +// API +export { createSttJob, getSummary, publishSummary, updateSummary } from './retrospectives.api' + +// Types +export type { + CreateSttJobParams, + KeyPoint, + KeyPointUpdateRequest, + PublishSummaryParams, + RetrospectiveSummaryResponse, + SttJobResponse, + SttJobStatus, + SummaryTopic, + UpdateSummaryParams, + UpdateSummaryRequest, + UpdateSummaryTopicRequest, +} from './retrospectives.types' diff --git a/src/features/retrospectives/retrospectives.api.ts b/src/features/retrospectives/retrospectives.api.ts new file mode 100644 index 0000000..1564b59 --- /dev/null +++ b/src/features/retrospectives/retrospectives.api.ts @@ -0,0 +1,82 @@ +/** + * @file retrospectives.api.ts + * @description 약속 회고 AI 요약 API 요청 함수 + */ + +import { api } from '@/api/client' + +import { RETROSPECTIVES_ENDPOINTS } from './retrospectives.endpoints' +import type { + CreateSttJobParams, + PublishSummaryParams, + RetrospectiveSummaryResponse, + SttJobResponse, + UpdateSummaryParams, +} from './retrospectives.types' + +/** STT 요청 타임아웃: 5분 (동기 API이므로 충분히 길게) */ +const STT_TIMEOUT = 5 * 60 * 1000 + +/** + * STT Job 생성 (AI 요약 트리거) + * + * @description + * 녹음 파일을 업로드하거나 사전 의견만으로 STT + AI 요약을 실행합니다. + * 동기 API이므로 완료될 때까지 블로킹됩니다. + * + * @param params - 생성 파라미터 + * @param signal - AbortSignal (취소 지원) + */ +export const createSttJob = async ( + params: CreateSttJobParams, + signal?: AbortSignal +): Promise => { + const { gatheringId, meetingId, file } = params + + const formData = new FormData() + if (file) { + formData.append('file', file) + } + + return api.post( + RETROSPECTIVES_ENDPOINTS.STT_JOBS(gatheringId, meetingId), + formData, + { + headers: { 'Content-Type': undefined }, + timeout: STT_TIMEOUT, + signal, + } + ) +} + +/** + * 회고 요약 조회 + * + * @param meetingId - 약속 식별자 + */ +export const getSummary = async (meetingId: number): Promise => { + return api.get(RETROSPECTIVES_ENDPOINTS.SUMMARY(meetingId)) +} + +/** + * 회고 요약 수정 + * + * @param params - 수정 파라미터 (meetingId + topics 데이터) + */ +export const updateSummary = async ( + params: UpdateSummaryParams +): Promise => { + const { meetingId, data } = params + return api.patch(RETROSPECTIVES_ENDPOINTS.SUMMARY(meetingId), data) +} + +/** + * 회고 요약 발행 (약속 회고 생성) + * + * @param params - 발행 파라미터 (meetingId) + */ +export const publishSummary = async ( + params: PublishSummaryParams +): Promise => { + return api.post(RETROSPECTIVES_ENDPOINTS.PUBLISH(params.meetingId)) +} diff --git a/src/features/retrospectives/retrospectives.endpoints.ts b/src/features/retrospectives/retrospectives.endpoints.ts new file mode 100644 index 0000000..9567887 --- /dev/null +++ b/src/features/retrospectives/retrospectives.endpoints.ts @@ -0,0 +1,14 @@ +import { API_PATHS } from '@/api' + +export const RETROSPECTIVES_ENDPOINTS = { + /** STT Job 생성 (POST) */ + STT_JOBS: (gatheringId: number, meetingId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/stt/jobs`, + + /** 회고 요약 조회/수정 (GET/PATCH) */ + SUMMARY: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}/retrospectives/summary`, + + /** 회고 요약 발행 (POST) */ + PUBLISH: (meetingId: number) => + `${API_PATHS.MEETINGS}/${meetingId}/retrospectives/summary/publish`, +} as const diff --git a/src/features/retrospectives/retrospectives.types.ts b/src/features/retrospectives/retrospectives.types.ts new file mode 100644 index 0000000..b03891c --- /dev/null +++ b/src/features/retrospectives/retrospectives.types.ts @@ -0,0 +1,84 @@ +/** + * @file retrospectives.types.ts + * @description 약속 회고 AI 요약 관련 타입 정의 + */ + +// ─── STT Job ─── + +/** STT Job 상태 */ +export type SttJobStatus = 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED' + +/** STT Job 생성 요청 파라미터 */ +export type CreateSttJobParams = { + gatheringId: number + meetingId: number + file?: File +} + +/** STT Job 응답 */ +export interface SttJobResponse { + jobId: number + meetingId: number + userId: number + status: SttJobStatus + summary: string | null + highlights: string[] | null + errorMessage: string | null + createdAt: string +} + +// ─── Retrospective Summary ─── + +/** 주요 포인트 항목 */ +export interface KeyPoint { + title: string + details: string[] +} + +/** 토픽별 요약 */ +export interface SummaryTopic { + topicId: number + confirmOrder: number + topicTitle: string + topicDescription: string + summary: string + keyPoints: KeyPoint[] +} + +/** 회고 요약 응답 (GET, PATCH, POST publish 공통) */ +export interface RetrospectiveSummaryResponse { + meetingId: number + isPublished: boolean + publishedAt: string | null + topics: SummaryTopic[] +} + +// ─── Request Types ─── + +/** 주요 포인트 수정 요청 (KeyPoint와 동일 형태) */ +export type KeyPointUpdateRequest = KeyPoint + +/** 토픽 요약 수정 요청 */ +export type UpdateSummaryTopicRequest = { + topicId: number + summary: string + keyPoints: KeyPointUpdateRequest[] +} + +/** 회고 요약 수정 요청 바디 */ +export type UpdateSummaryRequest = { + topics: UpdateSummaryTopicRequest[] +} + +// ─── Hook Params ─── + +/** 요약 수정 훅 파라미터 */ +export type UpdateSummaryParams = { + meetingId: number + data: UpdateSummaryRequest +} + +/** 요약 발행 훅 파라미터 */ +export type PublishSummaryParams = { + meetingId: number +}