diff --git a/fishing/apps/api/src/tournaments/dto/organizer-status.dto.ts b/fishing/apps/api/src/tournaments/dto/organizer-status.dto.ts new file mode 100644 index 0000000..e8a755c --- /dev/null +++ b/fishing/apps/api/src/tournaments/dto/organizer-status.dto.ts @@ -0,0 +1,6 @@ +import { IsIn } from 'class-validator'; + +export class OrganizerStatusDto { + @IsIn(['upcoming', 'active', 'closed', 'finished']) + status: 'upcoming' | 'active' | 'closed' | 'finished'; +} diff --git a/fishing/apps/api/src/tournaments/dto/review-day-code-claim.dto.ts b/fishing/apps/api/src/tournaments/dto/review-day-code-claim.dto.ts new file mode 100644 index 0000000..8e5a3f1 --- /dev/null +++ b/fishing/apps/api/src/tournaments/dto/review-day-code-claim.dto.ts @@ -0,0 +1,6 @@ +import { IsIn } from 'class-validator'; + +export class ReviewDayCodeClaimDto { + @IsIn(['accepted', 'rejected']) + status: 'accepted' | 'rejected'; +} diff --git a/fishing/apps/api/src/tournaments/organizer.service.spec.ts b/fishing/apps/api/src/tournaments/organizer.service.spec.ts index 1a46d31..abfabc0 100644 --- a/fishing/apps/api/src/tournaments/organizer.service.spec.ts +++ b/fishing/apps/api/src/tournaments/organizer.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { TournamentsService } from './tournaments.service'; describe('TournamentsService 운영자 대회', () => { @@ -6,6 +6,7 @@ describe('TournamentsService 운영자 대회', () => { user: { findUnique: jest.fn() }, tournament: { findUnique: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn() }, tournamentDayCode: { upsert: jest.fn() }, + catch: { findMany: jest.fn(), findFirst: jest.fn(), update: jest.fn() }, }; const service = new TournamentsService(prisma as never); const dto = { @@ -72,4 +73,56 @@ describe('TournamentsService 운영자 대회', () => { service.setTodayDayCode('org-2', 'tour-1', 'SHOP1', new Date('2026-10-20T03:00:00.000Z')), ).rejects.toBeInstanceOf(ForbiddenException); }); + + it('운영자는 자기 대회를 진행 중으로 연다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' }); + prisma.tournament.findUnique.mockResolvedValue({ id: 'tour-1', organizerId: 'org-1' }); + prisma.tournament.update.mockResolvedValue({ id: 'tour-1', status: 'active' }); + + await expect(service.updateOrganizerStatus('org-1', 'tour-1', 'active')).resolves.toEqual({ + id: 'tour-1', + status: 'active', + }); + }); + + it('알 수 없는 대회 상태는 저장하지 않는다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' }); + prisma.tournament.findUnique.mockResolvedValue({ id: 'tour-1', organizerId: 'org-1' }); + + await expect(service.updateOrganizerStatus('org-1', 'tour-1', 'paid')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(prisma.tournament.update).not.toHaveBeenCalled(); + }); + + it('운영자는 자기 대회에 제출된 코드만 수락한다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' }); + prisma.tournament.findUnique.mockResolvedValue({ id: 'tour-1', organizerId: 'org-1' }); + prisma.catch.findFirst.mockResolvedValue({ id: 'catch-1', claimedDayCode: 'SHOP1' }); + prisma.catch.update.mockResolvedValue({ id: 'catch-1', dayCodeStatus: 'accepted' }); + + await expect(service.reviewDayCodeClaim('org-1', 'tour-1', 'catch-1', 'accepted')).resolves.toEqual({ + id: 'catch-1', + dayCodeStatus: 'accepted', + }); + expect(prisma.catch.findFirst).toHaveBeenCalledWith({ + where: { + id: 'catch-1', + deletedAt: null, + claimedTournamentId: 'tour-1', + claimedDayCode: { not: null }, + }, + }); + }); + + it('다른 대회 제출은 수락하지 못한다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' }); + prisma.tournament.findUnique.mockResolvedValue({ id: 'tour-1', organizerId: 'org-1' }); + prisma.catch.findFirst.mockResolvedValue(null); + + await expect(service.reviewDayCodeClaim('org-1', 'tour-1', 'catch-9', 'accepted')).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(prisma.catch.update).not.toHaveBeenCalled(); + }); }); diff --git a/fishing/apps/api/src/tournaments/tournaments.controller.ts b/fishing/apps/api/src/tournaments/tournaments.controller.ts index 491e3e3..0ff3b25 100644 --- a/fishing/apps/api/src/tournaments/tournaments.controller.ts +++ b/fishing/apps/api/src/tournaments/tournaments.controller.ts @@ -10,6 +10,8 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'; import { TournamentsService } from './tournaments.service'; import { SetDayCodeDto } from './dto/set-day-code.dto'; import { OrganizerTournamentDto } from './dto/organizer-tournament.dto'; +import { OrganizerStatusDto } from './dto/organizer-status.dto'; +import { ReviewDayCodeClaimDto } from './dto/review-day-code-claim.dto'; import { SetParticipantDto } from './dto/set-participant.dto'; import { createImageUploadInterceptor, saveUploadedFile } from '../common/upload/upload.config'; @@ -93,6 +95,38 @@ export class TournamentsController { return { success: true, data: result }; } + @Patch('organized/:id/status') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '운영 대회 상태 변경. 참가 개방은 active' }) + async updateOrganizedStatus(@Param('id') id: string, @CurrentUser() user: any, @Body() body: OrganizerStatusDto) { + const result = await this.tournamentsService.updateOrganizerStatus(user.id, id, body.status); + return { success: true, data: result }; + } + + @Get('organized/:id/day-code-claims') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '이 대회에 제출된 일일 코드' }) + async listDayCodeClaims(@Param('id') id: string, @CurrentUser() user: any) { + const result = await this.tournamentsService.listDayCodeClaims(user.id, id); + return { success: true, data: result }; + } + + @Patch('organized/:id/day-code-claims/:catchId') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '제출된 일일 코드를 수락하거나 거절' }) + async reviewDayCodeClaim( + @Param('id') id: string, + @Param('catchId') catchId: string, + @CurrentUser() user: any, + @Body() body: ReviewDayCodeClaimDto, + ) { + const result = await this.tournamentsService.reviewDayCodeClaim(user.id, id, catchId, body.status); + return { success: true, data: result }; + } + @Patch('organized/:id') @UseGuards(JwtAuthGuard) @ApiBearerAuth() diff --git a/fishing/apps/api/src/tournaments/tournaments.service.ts b/fishing/apps/api/src/tournaments/tournaments.service.ts index 61d1269..4c40a52 100644 --- a/fishing/apps/api/src/tournaments/tournaments.service.ts +++ b/fishing/apps/api/src/tournaments/tournaments.service.ts @@ -249,6 +249,65 @@ export class TournamentsService { }); } + async updateOrganizerStatus(actorId: string, tournamentId: string, status: string) { + await this.assertManagesTournament(actorId, tournamentId); + if (!['upcoming', 'active', 'closed', 'finished'].includes(status)) { + throw new BadRequestException('대회 상태가 올바르지 않습니다.'); + } + return this.prisma.tournament.update({ + where: { id: tournamentId }, + data: { status }, + select: { id: true, status: true }, + }); + } + + async listDayCodeClaims(actorId: string, tournamentId: string) { + await this.assertManagesTournament(actorId, tournamentId); + return this.prisma.catch.findMany({ + where: { + deletedAt: null, + claimedTournamentId: tournamentId, + claimedDayCode: { not: null }, + }, + select: { + id: true, + lengthCm: true, + status: true, + imageUrl: true, + claimedDayCode: true, + dayCodeStatus: true, + integrityNote: true, + createdAt: true, + user: { select: { id: true, nickname: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + } + + async reviewDayCodeClaim( + actorId: string, + tournamentId: string, + catchId: string, + status: 'accepted' | 'rejected', + ) { + await this.assertManagesTournament(actorId, tournamentId); + const catch_ = await this.prisma.catch.findFirst({ + where: { + id: catchId, + deletedAt: null, + claimedTournamentId: tournamentId, + claimedDayCode: { not: null }, + }, + }); + if (!catch_) throw new NotFoundException('이 대회에 제출된 코드를 찾을 수 없습니다.'); + return this.prisma.catch.update({ + where: { id: catchId }, + data: { dayCodeStatus: status }, + select: { id: true, dayCodeStatus: true }, + }); + } + async setDisqualified(actorId: string, tournamentId: string, userId: string, disqualified: boolean, note?: string) { await this.assertManagesTournament(actorId, tournamentId); const entry = await this.prisma.tournamentEntry.findUnique({ diff --git a/fishing/apps/web/src/app/tournament/organize/page.tsx b/fishing/apps/web/src/app/tournament/organize/page.tsx index ccf76d1..edc80ad 100644 --- a/fishing/apps/web/src/app/tournament/organize/page.tsx +++ b/fishing/apps/web/src/app/tournament/organize/page.tsx @@ -4,8 +4,26 @@ import { useState, type ChangeEvent } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import api from '@/lib/api'; +import { getImageUrl } from '@/lib/images'; import PageHeader from '@/components/layout/PageHeader'; +const STATUS_OPTIONS = [ + { value: 'upcoming', label: '예정' }, + { value: 'active', label: '진행중' }, + { value: 'closed', label: '마감' }, + { value: 'finished', label: '종료' }, +] as const; + +const NOTE_LABELS: Record = { + date_missing: '촬영 시각 없음', + gps_missing: 'GPS 없음', +}; + +function noteLabel(note?: string | null) { + if (!note) return ''; + return note.split(',').map((part) => NOTE_LABELS[part] ?? part).join(', '); +} + const EMPTY = { title: '', description: '', @@ -28,6 +46,7 @@ export default function OrganizeTournamentPage() { const [form, setForm] = useState(EMPTY); const [message, setMessage] = useState(''); const [openId, setOpenId] = useState(null); + const [codes, setCodes] = useState>({}); const { data, isLoading } = useQuery({ queryKey: ['organized-tournaments'], @@ -72,6 +91,42 @@ export default function OrganizeTournamentPage() { enabled: !!openId, }); + const { data: managedCode } = useQuery({ + queryKey: ['tournament-day-code', openId], + queryFn: async () => (await api.get(`/tournaments/organized/${openId}/day-code`)).data.data as { codeDate: string; code: string | null }, + enabled: !!openId, + }); + + const { data: claims } = useQuery({ + queryKey: ['tournament-day-claims', openId], + queryFn: async () => (await api.get(`/tournaments/organized/${openId}/day-code-claims`)).data.data, + enabled: !!openId, + }); + + const statusMutation = useMutation({ + mutationFn: ({ id, status }: { id: string; status: string }) => + api.patch(`/tournaments/organized/${id}/status`, { status }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['organized-tournaments'] }), + }); + + const codeMutation = useMutation({ + mutationFn: ({ id, code }: { id: string; code: string }) => api.post(`/tournaments/${id}/day-code`, { code }), + onSuccess: (_data, variables) => { + setMessage('오늘 코드를 저장했습니다. 현장에 있는 참가자에게만 알려 주세요.'); + queryClient.invalidateQueries({ queryKey: ['tournament-day-code', variables.id] }); + }, + onError: (error: { response?: { data?: { message?: string | string[] } } }) => { + const apiMessage = error.response?.data?.message; + setMessage(Array.isArray(apiMessage) ? apiMessage.join(', ') : apiMessage || '코드를 저장하지 못했습니다.'); + }, + }); + + const claimMutation = useMutation({ + mutationFn: ({ tournamentId, catchId, status }: { tournamentId: string; catchId: string; status: 'accepted' | 'rejected' }) => + api.patch(`/tournaments/organized/${tournamentId}/day-code-claims/${catchId}`, { status }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tournament-day-claims', openId] }), + }); + const disqualifyMutation = useMutation({ mutationFn: ({ tournamentId, userId, disqualified }: { tournamentId: string; userId: string; disqualified: boolean }) => api.patch(`/tournaments/organized/${tournamentId}/participants/${userId}`, { disqualified }), @@ -139,39 +194,127 @@ export default function OrganizeTournamentPage() {

아직 없습니다.

) : ( data.map((tournament: { id: string; title: string; region?: string | null; sponsorName?: string | null; status: string }) => ( -
+

{tournament.title} - {' '}· {tournament.status} {tournament.region ? ` · ${tournament.region}` : ''} {tournament.sponsorName ? ` · ${tournament.sponsorName}` : ''}

+ - +
{openId === tournament.id && ( -
    - {(participants ?? []).map((entry: { id: string; userId: string; disqualified: boolean; user: { nickname: string } }) => ( -
  • - {entry.user.nickname} - {entry.disqualified ? ' · 실격' : ''} - -
  • - ))} -
+
+
+ setCodes((prev) => ({ ...prev, [tournament.id]: event.target.value }))} + /> + +
+

+ {managedCode?.code + ? `${managedCode.codeDate} 코드가 등록되어 있습니다. 공개 페이지에는 코드가 나오지 않습니다.` + : '오늘 코드가 없으면 순위는 기간 안 인증 기록을 그대로 집계합니다.'} +

+

코드 제출

+ {!claims?.length ? ( +

제출이 없습니다.

+ ) : ( +
    + {claims.map((claim: { + id: string; + lengthCm?: string | number | null; + status: string; + imageUrl?: string | null; + claimedDayCode?: string | null; + dayCodeStatus?: string | null; + integrityNote?: string | null; + user: { nickname: string }; + }) => { + const image = getImageUrl(claim.imageUrl); + const flags = noteLabel(claim.integrityNote); + return ( +
  • + {claim.user.nickname} + {' · '}{claim.claimedDayCode} + {' · '}{claim.dayCodeStatus} + {claim.lengthCm ? ` · ${Number(claim.lengthCm).toFixed(1)}cm` : ''} + {' · 인증 '}{claim.status} + {flags ? ` · ${flags}` : ''} + {image && ( + <> + {' '} + 사진 + + )} + + +
  • + ); + })} +
+ )} +

참가자

+
    + {(participants ?? []).map((entry: { id: string; userId: string; disqualified: boolean; user: { nickname: string } }) => ( +
  • + {entry.user.nickname} + {entry.disqualified ? ' · 실격' : ''} + +
  • + ))} +
+
)}
))