diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 18a953e..cd63797 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -689,6 +689,53 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' + /team-space/{projectGroupId}/dev-guide/history: + get: + tags: [TeamSpace] + security: + - bearerAuth: [] + summary: Get AI development guide version history + parameters: + - $ref: '#/components/parameters/ProjectGroupId' + responses: + '200': + description: AI development guide version history + content: + application/json: + schema: + $ref: '#/components/schemas/DevGuideHistoryListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + /team-space/{projectGroupId}/dev-guide/history/{devGuideId}: + get: + tags: [TeamSpace] + security: + - bearerAuth: [] + summary: Get AI development guide version content + parameters: + - $ref: '#/components/parameters/ProjectGroupId' + - $ref: '#/components/parameters/DevGuideId' + responses: + '200': + description: AI development guide version content + content: + application/json: + schema: + $ref: '#/components/schemas/DevGuideHistoryContentResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' /team-space/{projectGroupId}/dev-guide/regenerate: post: tags: [TeamSpace] @@ -724,6 +771,28 @@ paths: $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/InternalServerError' + /team-space/{projectGroupId}/dev-guide/{devGuideId}/confirm: + post: + tags: [TeamSpace] + security: + - bearerAuth: [] + summary: Confirm an AI development guide version + parameters: + - $ref: '#/components/parameters/ProjectGroupId' + - $ref: '#/components/parameters/DevGuideId' + responses: + '200': + description: AI development guide version confirmed + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' /team-space/{projectGroupId}/github/status: get: tags: [TeamSpace] @@ -905,6 +974,30 @@ paths: $ref: '#/components/responses/NotFound' '502': $ref: '#/components/responses/BadGateway' + /team-space/{projectGroupId}/github/users/{targetUserId}/weekly-summaries: + get: + tags: [TeamSpace] + security: + - bearerAuth: [] + summary: Get weekly GitHub activity summaries for a team member + parameters: + - $ref: '#/components/parameters/ProjectGroupId' + - $ref: '#/components/parameters/TargetUserId' + responses: + '200': + description: Weekly GitHub summaries + content: + application/json: + schema: + $ref: '#/components/schemas/GithubWeeklySummaryListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' components: securitySchemes: bearerAuth: @@ -933,6 +1026,13 @@ components: schema: type: integer format: int64 + DevGuideId: + in: path + name: devGuideId + required: true + schema: + type: integer + format: int64 GithubRepositoryId: in: path name: githubRepositoryId @@ -1589,6 +1689,75 @@ components: contributionScore: type: integer format: int64 + GithubWeeklySummaryListResponse: + type: object + additionalProperties: false + required: [summaries] + properties: + summaries: + type: array + items: + $ref: '#/components/schemas/GithubWeeklySummary' + GithubWeeklySummary: + type: object + additionalProperties: false + required: + - weeklyGithubSummaryId + - periodStart + - periodEnd + - sourcePrCount + - sourceIssueCount + - summary + properties: + weeklyGithubSummaryId: + type: integer + format: int64 + periodStart: + type: string + format: date-time + periodEnd: + type: string + format: date-time + sourcePrCount: + type: integer + format: int64 + sourceIssueCount: + type: integer + format: int64 + summary: + $ref: '#/components/schemas/GithubWeeklySummaryContent' + GithubWeeklySummaryContent: + type: object + additionalProperties: false + required: + - summary + - mainActivities + - pullRequestHighlights + - issueHighlights + - followUpSuggestions + properties: + summary: + type: string + mainActivities: + type: array + maxItems: 5 + items: + type: string + pullRequestHighlights: + type: array + maxItems: 5 + items: + type: string + issueHighlights: + type: array + maxItems: 5 + items: + type: string + followUpSuggestions: + type: array + maxItems: 3 + items: + type: string DevGuideGenerationStatus: type: string enum: [GENERATING, COMPLETED, FAILED] @@ -1654,6 +1823,85 @@ components: type: - integer - 'null' + DevGuideHistory: + type: object + additionalProperties: false + required: [devGuideId, versionNo, generationType, confirmed, createdAt] + properties: + devGuideId: + type: integer + format: int64 + versionNo: + type: integer + generationType: + $ref: '#/components/schemas/DevGuideGenerationType' + confirmed: + type: boolean + createdAt: + type: string + format: date-time + DevGuideHistoryListResponse: + type: object + additionalProperties: false + required: [histories] + properties: + histories: + type: array + items: + $ref: '#/components/schemas/DevGuideHistory' + DevGuideHistoryContentResponse: + type: object + additionalProperties: false + required: + - devGuideId + - versionNo + - generationType + - confirmed + - createdAt + - overview + - techStack + - mvpPriorities + - decisionPoints + - milestones + properties: + devGuideId: + type: integer + format: int64 + versionNo: + type: integer + generationType: + $ref: '#/components/schemas/DevGuideGenerationType' + confirmed: + type: boolean + createdAt: + type: string + format: date-time + overview: + type: string + techStack: + type: array + minItems: 5 + maxItems: 7 + items: + $ref: '#/components/schemas/DevGuideTechStackItem' + mvpPriorities: + type: array + minItems: 3 + maxItems: 3 + items: + $ref: '#/components/schemas/DevGuideMvpPriority' + decisionPoints: + type: array + minItems: 3 + maxItems: 5 + items: + $ref: '#/components/schemas/DevGuideDecisionPoint' + milestones: + type: array + minItems: 12 + maxItems: 12 + items: + $ref: '#/components/schemas/DevGuideMilestone' DevGuideContent: type: object additionalProperties: false diff --git a/src/features/team/components/real-github-installation-panel.tsx b/src/features/team/components/real-github-installation-panel.tsx index e5da9ca..d1985a4 100644 --- a/src/features/team/components/real-github-installation-panel.tsx +++ b/src/features/team/components/real-github-installation-panel.tsx @@ -9,6 +9,7 @@ import { Save, Sparkles, Trophy, + UserRound, type LucideIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -28,16 +29,22 @@ import { useGithubInstallationStatusQuery, useGithubRepositoriesQuery, useGithubRepositoryContributionsQuery, + useGithubWeeklySummariesQuery, useSetGithubRepositoriesMutation, useSyncGithubPullRequestContributionsMutation, } from "@/features/team/hooks/use-team-space-queries"; import { getApiErrorMessage } from "@/lib/api/client"; -import type { MyProjectGroup } from "@/lib/types/project-group"; +import type { + MyProjectGroup, + ProjectGroupMember, +} from "@/lib/types/project-group"; import type { GithubRepository, GithubRepositoryContributor, + GithubWeeklySummary, } from "@/lib/types/team-space"; import { cn } from "@/lib/utils"; +import { formatDateTime } from "@/lib/utils/date"; const contributionNumberFormatter = new Intl.NumberFormat("ko-KR"); @@ -435,11 +442,200 @@ export function RealGithubInstallationPanel({ ) : null} + + ); } +function RealGithubWeeklySummariesPanel({ + isGithubConnected, + members, + projectGroupId, +}: { + isGithubConnected: boolean; + members: ProjectGroupMember[]; + projectGroupId: number; +}) { + return ( +
+
+
+

팀원 주간 요약

+

+ {isGithubConnected + ? "GitHub PR과 이슈를 바탕으로 팀원별 최근 활동을 확인해요." + : "GitHub 연결 전에는 서버에 저장된 최근 요약을 먼저 확인해요."} +

+
+
+ + {isGithubConnected ? "연결된 요약" : "저장된 요약"} + + {members.length}명 +
+
+ +
+ {members.map((member) => ( + + ))} +
+
+ ); +} + +function RealGithubWeeklySummaryCard({ + member, + projectGroupId, +}: { + member: ProjectGroupMember; + projectGroupId: number; +}) { + const weeklySummariesQuery = useGithubWeeklySummariesQuery( + projectGroupId, + member.userId, + ); + const latestSummary = weeklySummariesQuery.data?.summaries[0] ?? null; + + return ( +
+
+
+ +
+
+
+
+

+ {member.nickname} +

+

+ {member.memberRole} +

+
+ {latestSummary ? ( + + PR{" "} + {contributionNumberFormatter.format( + latestSummary.sourcePrCount, + )} + + ) : ( + 요약 대기 + )} +
+ + {weeklySummariesQuery.isLoading ? ( + } + message="주간 요약을 불러오고 있어요." + /> + ) : null} + + {weeklySummariesQuery.error ? ( + + ) : null} + + {latestSummary ? ( + + ) : null} + + {weeklySummariesQuery.isSuccess && !latestSummary ? ( +

+ 아직 생성된 주간 요약이 없어요. +

+ ) : null} +
+
+
+ ); +} + +function RealGithubWeeklySummaryBody({ + summary, +}: { + summary: GithubWeeklySummary; +}) { + return ( +
+
+

+ {formatDateTime(summary.periodStart)} -{" "} + {formatDateTime(summary.periodEnd)} +

+

+ {summary.summary.summary} +

+
+ +
+ + +
+ + + +
+ ); +} + +function RealGithubWeeklySummaryList({ + items, + title, +}: { + items: string[]; + title: string; +}) { + if (items.length === 0) { + return null; + } + + return ( +
+

{title}

+ +
+ ); +} + function RealGithubStatusCard({ label, ready, @@ -792,7 +988,7 @@ function RealGithubContributionStat({
-

{label}

+

{label}

{contributionNumberFormatter.format(value)} diff --git a/src/features/team/components/real-team-guide-panel.tsx b/src/features/team/components/real-team-guide-panel.tsx index c5dc2ab..8515015 100644 --- a/src/features/team/components/real-team-guide-panel.tsx +++ b/src/features/team/components/real-team-guide-panel.tsx @@ -1,10 +1,20 @@ -import { CheckCircle2, LoaderCircle, RefreshCw } from "lucide-react"; +import { + Check, + CheckCircle2, + History, + LoaderCircle, + RefreshCw, +} from "lucide-react"; +import { useState } from "react"; import { AppPanel, AppPanelHeader } from "@/components/app-shell"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { RealInlineStatus } from "@/features/team/components/real-team-shared"; import { + useConfirmDevGuideMutation, + useDevGuideHistoriesQuery, + useDevGuideHistoryContentQuery, useDevGuideQuery, useRegenerateDevGuideMutation, } from "@/features/team/hooks/use-team-space-queries"; @@ -13,14 +23,19 @@ import type { MyProjectGroup } from "@/lib/types/project-group"; import type { DevGuideContent, DevGuideGenerationStatus, + DevGuideHistory, DevGuideQueryResponse, } from "@/lib/types/team-space"; +import { formatDateTime } from "@/lib/utils/date"; export function RealGuidePanel({ projectGroup, }: { projectGroup: MyProjectGroup; }) { + const [selectedHistoryId, setSelectedHistoryId] = useState( + null, + ); const devGuideQuery = useDevGuideQuery(projectGroup.projectGroupId); const regenerateDevGuideMutation = useRegenerateDevGuideMutation(); const guideResponse = devGuideQuery.data; @@ -28,6 +43,21 @@ export function RealGuidePanel({ const generationStatus = guideResponse?.generationStatus; const remainingRegenerationCount = guideResponse?.remainingRegenerationCount ?? null; + const historiesQuery = useDevGuideHistoriesQuery( + projectGroup.projectGroupId, + Boolean(guideResponse) && generationStatus !== "GENERATING", + ); + const histories = historiesQuery.data?.histories ?? []; + const selectedHistory = + histories.find((history) => history.devGuideId === selectedHistoryId) ?? + histories[0] ?? + null; + const historyContentQuery = useDevGuideHistoryContentQuery( + projectGroup.projectGroupId, + selectedHistory?.devGuideId, + Boolean(selectedHistory), + ); + const confirmDevGuideMutation = useConfirmDevGuideMutation(); const handleRegenerateDevGuide = () => { regenerateDevGuideMutation.mutate({ @@ -35,6 +65,13 @@ export function RealGuidePanel({ }); }; + const handleConfirmDevGuide = (devGuideId: number) => { + confirmDevGuideMutation.mutate({ + devGuideId, + projectGroupId: projectGroup.projectGroupId, + }); + }; + if (devGuideQuery.isLoading) { return ( @@ -225,6 +262,20 @@ export function RealGuidePanel({

+ +
@@ -470,6 +521,217 @@ function RealDevGuideMilestonePanel({ guide }: { guide: DevGuideContent }) { ); } +function RealDevGuideHistoryPanel({ + confirmError, + histories, + historiesError, + isConfirming, + isLoading, + onConfirm, + onSelectHistory, + selectedHistory, + selectedHistoryContent, + selectedHistoryContentError, + selectedHistoryContentLoading, +}: { + confirmError: unknown; + histories: DevGuideHistory[]; + historiesError: unknown; + isConfirming: boolean; + isLoading: boolean; + onConfirm: (devGuideId: number) => void; + onSelectHistory: (devGuideId: number) => void; + selectedHistory: DevGuideHistory | null; + selectedHistoryContent: (DevGuideContent & DevGuideHistory) | null; + selectedHistoryContentError: unknown; + selectedHistoryContentLoading: boolean; +}) { + const canConfirmSelected = selectedHistory + ? !selectedHistory.confirmed + : false; + + return ( + + 버전 {histories.length}개} + description="서버에 저장된 개발 가이드 버전을 비교하고 팀 기준 버전으로 확정해요." + eyebrow="History" + title="가이드 버전 기록" + /> +
+
+ {isLoading ? ( + } + message="가이드 버전 기록을 불러오고 있어요." + /> + ) : null} + + {historiesError ? ( + + ) : null} + + {!isLoading && !historiesError && histories.length === 0 ? ( + + ) : null} + + {histories.map((history) => ( + + ))} +
+ +
+
+
+

+ {selectedHistory + ? `v${selectedHistory.versionNo} 본문` + : "선택된 버전 없음"} +

+

+ {selectedHistory + ? formatDateTime(selectedHistory.createdAt) + : "기록을 선택하면 본문 요약이 표시돼요."} +

+
+ +
+ + {confirmError ? ( + + ) : null} + + {selectedHistoryContentLoading ? ( + } + message="선택한 버전의 본문을 불러오고 있어요." + /> + ) : null} + + {selectedHistoryContentError ? ( + + ) : null} + + {selectedHistoryContent ? ( +
+

+ {selectedHistoryContent.overview} +

+
+ + + +
+
+ ) : null} +
+
+
+ ); +} + +function RealDevGuideHistoryMetric({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + +function getDevGuideGenerationTypeLabel( + type: DevGuideHistory["generationType"], +) { + if (type === "MANUAL") { + return "수동"; + } + + if (type === "RECOVERY") { + return "복구"; + } + + return "초기"; +} + function RealDevGuideRoleTask({ label, value, diff --git a/src/features/team/hooks/use-team-space-queries.ts b/src/features/team/hooks/use-team-space-queries.ts index 1dc28a2..2a268a8 100644 --- a/src/features/team/hooks/use-team-space-queries.ts +++ b/src/features/team/hooks/use-team-space-queries.ts @@ -2,18 +2,23 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError } from "@/lib/api/client"; import { + confirmDevGuide, completeGithubAppInstallation, createGithubAppInstallationUrl, getAvailableGithubRepositories, getDevGuide, + getDevGuideHistories, + getDevGuideHistoryContent, getGithubInstallationStatus, getGithubRepositories, getGithubRepositoryContributions, + getGithubWeeklySummaries, regenerateDevGuide, setGithubRepositories, syncGithubPullRequestContributions, } from "@/lib/api/team-space"; import type { + ConfirmDevGuideRequest, DevGuideQueryResponse, GithubAppInstallationCompleteRequest, GithubRepositoryContributionRequest, @@ -25,6 +30,10 @@ export const teamSpaceQueryKeys = { all: ["team-space"] as const, devGuide: (projectGroupId: number) => ["team-space", projectGroupId, "dev-guide"] as const, + devGuideHistories: (projectGroupId: number) => + ["team-space", projectGroupId, "dev-guide", "history"] as const, + devGuideHistoryContent: (projectGroupId: number, devGuideId: number) => + ["team-space", projectGroupId, "dev-guide", "history", devGuideId] as const, disabled: ["team-space", "disabled"] as const, githubAvailableRepositories: (projectGroupId: number) => ["team-space", projectGroupId, "github", "available-repositories"] as const, @@ -42,6 +51,15 @@ export const teamSpaceQueryKeys = { githubRepositoryId, "contributions", ] as const, + githubWeeklySummaries: (projectGroupId: number, targetUserId: number) => + [ + "team-space", + projectGroupId, + "github", + "users", + targetUserId, + "weekly-summaries", + ] as const, githubStatus: (projectGroupId: number) => ["team-space", projectGroupId, "github", "status"] as const, }; @@ -60,6 +78,14 @@ function requireProjectGroupId(projectGroupId: number | undefined) { return projectGroupId; } +function requireNumericId(value: number | undefined, label: string) { + if (typeof value !== "number") { + throw new Error(`${label} is required.`); + } + + return value; +} + export function useGithubInstallationStatusQuery( projectGroupId: number | undefined, enabled = true, @@ -124,6 +150,76 @@ export function useRegenerateDevGuideMutation() { response.remainingRegenerationCount ?? null, }, ); + queryClient.invalidateQueries({ + queryKey: teamSpaceQueryKeys.devGuideHistories( + variables.projectGroupId, + ), + }); + }, + }); +} + +export function useDevGuideHistoriesQuery( + projectGroupId: number | undefined, + enabled = true, +) { + return useQuery({ + enabled: enabled && typeof projectGroupId === "number", + queryFn: () => getDevGuideHistories(requireProjectGroupId(projectGroupId)), + queryKey: + typeof projectGroupId === "number" + ? teamSpaceQueryKeys.devGuideHistories(projectGroupId) + : teamSpaceQueryKeys.disabled, + refetchOnWindowFocus: false, + retry: false, + staleTime: devGuideStaleTimeMs, + }); +} + +export function useDevGuideHistoryContentQuery( + projectGroupId: number | undefined, + devGuideId: number | undefined, + enabled = true, +) { + const hasIds = + typeof projectGroupId === "number" && typeof devGuideId === "number"; + + return useQuery({ + enabled: enabled && hasIds, + queryFn: () => + getDevGuideHistoryContent({ + devGuideId: requireNumericId(devGuideId, "Dev guide id"), + projectGroupId: requireProjectGroupId(projectGroupId), + }), + queryKey: hasIds + ? teamSpaceQueryKeys.devGuideHistoryContent(projectGroupId, devGuideId) + : teamSpaceQueryKeys.disabled, + refetchOnWindowFocus: false, + retry: false, + staleTime: devGuideStaleTimeMs, + }); +} + +export function useConfirmDevGuideMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: ConfirmDevGuideRequest) => confirmDevGuide(payload), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: teamSpaceQueryKeys.devGuide(variables.projectGroupId), + }); + queryClient.invalidateQueries({ + queryKey: teamSpaceQueryKeys.devGuideHistories( + variables.projectGroupId, + ), + }); + queryClient.invalidateQueries({ + queryKey: teamSpaceQueryKeys.devGuideHistoryContent( + variables.projectGroupId, + variables.devGuideId, + ), + }); }, }); } @@ -262,3 +358,27 @@ export function useSyncGithubPullRequestContributionsMutation() { }, }); } + +export function useGithubWeeklySummariesQuery( + projectGroupId: number | undefined, + targetUserId: number | undefined, + enabled = true, +) { + const hasIds = + typeof projectGroupId === "number" && typeof targetUserId === "number"; + + return useQuery({ + enabled: enabled && hasIds, + queryFn: () => + getGithubWeeklySummaries({ + projectGroupId: requireProjectGroupId(projectGroupId), + targetUserId: requireNumericId(targetUserId, "Target user id"), + }), + queryKey: hasIds + ? teamSpaceQueryKeys.githubWeeklySummaries(projectGroupId, targetUserId) + : teamSpaceQueryKeys.disabled, + refetchOnWindowFocus: false, + retry: false, + staleTime: githubContributionStaleTimeMs, + }); +} diff --git a/src/lib/api/mocks/handlers.ts b/src/lib/api/mocks/handlers.ts index db4f2a5..250efcd 100644 --- a/src/lib/api/mocks/handlers.ts +++ b/src/lib/api/mocks/handlers.ts @@ -39,11 +39,13 @@ import type { MyProjectGroup } from "@/lib/types/project-group"; import type { DevGuideContent, DevGuideGenerationStatus, + DevGuideHistoryContentResponse, DevGuideQueryResponse, GithubAppInstallationCompleteRequest, GithubInstallationStatus, GithubRepository, GithubRepositoryContributionResponse, + GithubWeeklySummaryListResponse, RegenerateDevGuideResponse, } from "@/lib/types/team-space"; import type { @@ -72,6 +74,8 @@ let activeDevGuideGenerationStatus: DevGuideGenerationStatus | null = let remainingDevGuideRegenerationCount: number | null = activeDevGuide ? 3 : null; +let activeDevGuideHistories = createMockDevGuideHistories(activeDevGuide); +let nextDevGuideId = 902; let nextProjectChecklistId = 103; let githubInstallationStatus: GithubInstallationStatus = createDisconnectedGithubStatus(); @@ -272,6 +276,8 @@ function resetTeamSpaceApiState() { activeDevGuide = createMockDevGuide(activeProjectGroup); activeDevGuideGenerationStatus = activeDevGuide ? "COMPLETED" : null; remainingDevGuideRegenerationCount = activeDevGuide ? 3 : null; + activeDevGuideHistories = createMockDevGuideHistories(activeDevGuide); + nextDevGuideId = 902; nextProjectChecklistId = 103; githubInstallationStatus = createDisconnectedGithubStatus(); clearPendingGithubInstallationState(); @@ -310,6 +316,106 @@ function createRegeneratedMockDevGuide(feedback: string | undefined) { } satisfies DevGuideContent; } +function createMockDevGuideHistories( + guide: DevGuideContent | null, +): DevGuideHistoryContentResponse[] { + if (!guide) { + return []; + } + + return [ + { + ...guide, + confirmed: true, + createdAt: "2026-05-24T09:00:00Z", + devGuideId: 901, + generationType: "INITIAL", + versionNo: 2, + }, + { + ...guide, + confirmed: false, + createdAt: "2026-05-20T09:00:00Z", + devGuideId: 900, + generationType: "INITIAL", + overview: `${guide.overview} 초기 버전은 체크리스트 운영보다 팀 규칙 정리에 더 무게를 두었습니다.`, + versionNo: 1, + }, + ]; +} + +function addMockDevGuideHistory({ + content, + generationType, +}: { + content: DevGuideContent; + generationType: DevGuideHistoryContentResponse["generationType"]; +}) { + const nextVersionNo = + Math.max( + 0, + ...activeDevGuideHistories.map((history) => history.versionNo), + ) + 1; + + activeDevGuideHistories = [ + { + ...content, + confirmed: false, + createdAt: new Date().toISOString(), + devGuideId: nextDevGuideId, + generationType, + versionNo: nextVersionNo, + }, + ...activeDevGuideHistories, + ]; + nextDevGuideId += 1; +} + +function createMockGithubWeeklySummaries( + targetUserId: number, +): GithubWeeklySummaryListResponse { + const member = activeProjectGroup?.members.find( + (candidate) => candidate.userId === targetUserId, + ); + + if (!member) { + return { summaries: [] }; + } + + const roleLabel = member.memberRole.toLowerCase(); + + return { + summaries: [ + { + periodEnd: "2026-06-07T14:59:59Z", + periodStart: "2026-06-01T15:00:00Z", + sourceIssueCount: 3, + sourcePrCount: 4, + summary: { + followUpSuggestions: [ + "리뷰 대기 PR의 merge 기준을 팀 체크리스트에 남겨요.", + "다음 주에는 변경 파일이 큰 PR을 더 작게 나눠요.", + ], + issueHighlights: [ + "매칭 플로우 예외 케이스를 이슈로 정리했어요.", + "팀 스페이스 진입 조건을 재확인했어요.", + ], + mainActivities: [ + `${member.nickname}님은 ${roleLabel} 작업 흐름을 안정화했어요.`, + "팀 스페이스 API 계약과 화면 상태를 함께 점검했어요.", + ], + pullRequestHighlights: [ + "GitHub 연동 상태 카드의 실패/로딩 표시를 다듬었어요.", + "체크리스트 API 응답과 mock 데이터를 맞췄어요.", + ], + summary: `${member.nickname}님은 이번 주 팀 운영 기능의 연결성과 검증 흐름을 개선했습니다.`, + }, + weeklyGithubSummaryId: targetUserId * 1000 + 1, + }, + ], + }; +} + async function readOptionalJsonBody(request: Request) { try { return (await request.json()) as T; @@ -2002,6 +2108,77 @@ export const handlers = [ }, ), + http.get( + getPath("/team-space/:projectGroupId/dev-guide/history"), + async ({ params, request }) => { + await delay(250); + syncSessionFromRequest(request); + + const projectGroupId = Number(params.projectGroupId); + const accessError = assertProjectGroupAccess(projectGroupId); + + if (accessError) { + return accessError; + } + + if (activeDevGuideGenerationStatus === "GENERATING") { + return buildErrorResponse( + 409, + "개발 가이드라인이 생성 중입니다.", + "DEV_GUIDE_GENERATING", + ); + } + + return HttpResponse.json({ + histories: activeDevGuideHistories.map( + ({ + confirmed, + createdAt, + devGuideId, + generationType, + versionNo, + }) => ({ + confirmed, + createdAt, + devGuideId, + generationType, + versionNo, + }), + ), + }); + }, + ), + + http.get( + getPath("/team-space/:projectGroupId/dev-guide/history/:devGuideId"), + async ({ params, request }) => { + await delay(250); + syncSessionFromRequest(request); + + const projectGroupId = Number(params.projectGroupId); + const devGuideId = Number(params.devGuideId); + const accessError = assertProjectGroupAccess(projectGroupId); + + if (accessError) { + return accessError; + } + + const history = activeDevGuideHistories.find( + (candidate) => candidate.devGuideId === devGuideId, + ); + + if (!history) { + return buildErrorResponse( + 404, + "개발 가이드라인이 존재하지 않습니다.", + "DEV_GUIDE_NOT_FOUND", + ); + } + + return HttpResponse.json(history); + }, + ), + http.post( getPath("/team-space/:projectGroupId/dev-guide/regenerate"), async ({ params, request }) => { @@ -2070,6 +2247,10 @@ export const handlers = [ remainingDevGuideRegenerationCount = remainingDevGuideRegenerationCount ?? 3; } + addMockDevGuideHistory({ + content: activeDevGuide, + generationType, + }); return HttpResponse.json({ content: activeDevGuide, @@ -2079,6 +2260,56 @@ export const handlers = [ }, ), + http.post( + getPath("/team-space/:projectGroupId/dev-guide/:devGuideId/confirm"), + async ({ params, request }) => { + await delay(350); + syncSessionFromRequest(request); + + const projectGroupId = Number(params.projectGroupId); + const devGuideId = Number(params.devGuideId); + const accessError = assertProjectGroupAccess(projectGroupId); + + if (accessError) { + return accessError; + } + + if (activeDevGuideGenerationStatus === "GENERATING") { + return buildErrorResponse( + 409, + "개발 가이드라인이 생성 중입니다.", + "DEV_GUIDE_GENERATING", + ); + } + + const history = activeDevGuideHistories.find( + (candidate) => candidate.devGuideId === devGuideId, + ); + + if (!history) { + return buildErrorResponse( + 404, + "개발 가이드라인이 존재하지 않습니다.", + "DEV_GUIDE_NOT_FOUND", + ); + } + + activeDevGuideHistories = activeDevGuideHistories.map((candidate) => ({ + ...candidate, + confirmed: candidate.devGuideId === devGuideId, + })); + activeDevGuide = { + decisionPoints: history.decisionPoints, + milestones: history.milestones, + mvpPriorities: history.mvpPriorities, + overview: history.overview, + techStack: history.techStack, + }; + + return new HttpResponse(null, { status: 200 }); + }, + ), + http.get( getPath("/team-space/:projectGroupId/github/status"), async ({ params, request }) => { @@ -2374,4 +2605,36 @@ export const handlers = [ return new HttpResponse(null, { status: 200 }); }, ), + + http.get( + getPath( + "/team-space/:projectGroupId/github/users/:targetUserId/weekly-summaries", + ), + async ({ params, request }) => { + await delay(300); + syncSessionFromRequest(request); + + const projectGroupId = Number(params.projectGroupId); + const targetUserId = Number(params.targetUserId); + const accessError = assertProjectGroupAccess(projectGroupId); + + if (accessError) { + return accessError; + } + + const targetMember = activeProjectGroup?.members.find( + (member) => member.userId === targetUserId, + ); + + if (!targetMember) { + return buildErrorResponse( + 404, + "팀 멤버를 찾을 수 없습니다.", + "PROJECT_GROUP_MEMBER_NOT_FOUND", + ); + } + + return HttpResponse.json(createMockGithubWeeklySummaries(targetUserId)); + }, + ), ]; diff --git a/src/lib/api/team-space.contract.ts b/src/lib/api/team-space.contract.ts new file mode 100644 index 0000000..4b64f4b --- /dev/null +++ b/src/lib/api/team-space.contract.ts @@ -0,0 +1,33 @@ +import { + confirmDevGuide, + getDevGuideHistories, + getDevGuideHistoryContent, + getGithubWeeklySummaries, +} from "@/lib/api/team-space"; +import type { + ConfirmDevGuideRequest, + DevGuideHistoryContentResponse, + DevGuideHistoryListResponse, + GithubWeeklySummaryListResponse, + GithubWeeklySummaryRequest, +} from "@/lib/types/team-space"; + +type TeamSpaceServerOnlyApiContract = { + confirmDevGuide: (payload: ConfirmDevGuideRequest) => Promise; + getDevGuideHistories: ( + projectGroupId: number, + ) => Promise; + getDevGuideHistoryContent: ( + payload: ConfirmDevGuideRequest, + ) => Promise; + getGithubWeeklySummaries: ( + payload: GithubWeeklySummaryRequest, + ) => Promise; +}; + +export const teamSpaceServerOnlyApiContract = { + confirmDevGuide, + getDevGuideHistories, + getDevGuideHistoryContent, + getGithubWeeklySummaries, +} satisfies TeamSpaceServerOnlyApiContract; diff --git a/src/lib/api/team-space.ts b/src/lib/api/team-space.ts index 45c6a7d..d2526e1 100644 --- a/src/lib/api/team-space.ts +++ b/src/lib/api/team-space.ts @@ -1,5 +1,8 @@ import { apiRequest } from "@/lib/api/client"; import type { + ConfirmDevGuideRequest, + DevGuideHistoryContentResponse, + DevGuideHistoryListResponse, DevGuideQueryResponse, GithubAppInstallationCompleteRequest, GithubAppInstallationUrl, @@ -7,6 +10,8 @@ import type { GithubRepositoryContributionRequest, GithubRepositoryContributionResponse, GithubRepositoryListResponse, + GithubWeeklySummaryListResponse, + GithubWeeklySummaryRequest, RegenerateDevGuideRequest, RegenerateDevGuideResponse, SetGithubRepositoriesRequest, @@ -33,6 +38,33 @@ export function regenerateDevGuide({ ); } +export function getDevGuideHistories(projectGroupId: number) { + return apiRequest( + `/team-space/${projectGroupId}/dev-guide/history`, + ); +} + +export function getDevGuideHistoryContent({ + devGuideId, + projectGroupId, +}: ConfirmDevGuideRequest) { + return apiRequest( + `/team-space/${projectGroupId}/dev-guide/history/${devGuideId}`, + ); +} + +export function confirmDevGuide({ + devGuideId, + projectGroupId, +}: ConfirmDevGuideRequest) { + return apiRequest( + `/team-space/${projectGroupId}/dev-guide/${devGuideId}/confirm`, + { + method: "POST", + }, + ); +} + export function getGithubInstallationStatus(projectGroupId: number) { return apiRequest( `/team-space/${projectGroupId}/github/status`, @@ -111,3 +143,12 @@ export function syncGithubPullRequestContributions({ }, ); } + +export function getGithubWeeklySummaries({ + projectGroupId, + targetUserId, +}: GithubWeeklySummaryRequest) { + return apiRequest( + `/team-space/${projectGroupId}/github/users/${targetUserId}/weekly-summaries`, + ); +} diff --git a/src/lib/types/team-space.ts b/src/lib/types/team-space.ts index 5dfac02..134efb3 100644 --- a/src/lib/types/team-space.ts +++ b/src/lib/types/team-space.ts @@ -112,3 +112,48 @@ export interface RegenerateDevGuideResponse { generationType: DevGuideGenerationType; remainingRegenerationCount?: number | null; } + +export interface ConfirmDevGuideRequest { + devGuideId: number; + projectGroupId: number; +} + +export interface DevGuideHistory { + confirmed: boolean; + createdAt: string; + devGuideId: number; + generationType: DevGuideGenerationType; + versionNo: number; +} + +export interface DevGuideHistoryListResponse { + histories: DevGuideHistory[]; +} + +export type DevGuideHistoryContentResponse = DevGuideContent & DevGuideHistory; + +export interface GithubWeeklySummaryRequest { + projectGroupId: number; + targetUserId: number; +} + +export interface GithubWeeklySummaryContent { + followUpSuggestions: string[]; + issueHighlights: string[]; + mainActivities: string[]; + pullRequestHighlights: string[]; + summary: string; +} + +export interface GithubWeeklySummary { + periodEnd: string; + periodStart: string; + sourceIssueCount: number; + sourcePrCount: number; + summary: GithubWeeklySummaryContent; + weeklyGithubSummaryId: number; +} + +export interface GithubWeeklySummaryListResponse { + summaries: GithubWeeklySummary[]; +}